Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TypeScript typings #105

Open
benneq opened this issue Jan 8, 2019 · 31 comments
Open

TypeScript typings #105

benneq opened this issue Jan 8, 2019 · 31 comments

Comments

@benneq
Copy link

benneq commented Jan 8, 2019

I created a pull request in the DefinitelyTyped repository: DefinitelyTyped/DefinitelyTyped#31953

It's basically working, but some things could be nicer (or better typed):

  1. Generics for Spec and Result
  2. Typings if SpecValue is a function

Especially point 2 is hard for me to figure out...

Example(s):

const spec = {
    a: [[ values => R.all((val) => false, values), 'msg']],
    b: R.map(R.always([[(val) => false, 'msg']])),
    c: R.map(() => [[(val) => false, 'msg']])
}

I have no clue what those function signatures for a, b and c are. I know from your source code, that those functions have a single argument (= value). But I have no clue about the return types.

I'm no Ramda / Functional Programming export. Though some help would be appreciated 😄
Especially when it comes to currying I'm lost 😆

@busypeoples
Copy link
Owner

Excellent thank you very much! I will try to have look in the coming days.

@benneq
Copy link
Author

benneq commented Jan 14, 2019

It finally got merged :)

@benneq
Copy link
Author

benneq commented Jan 15, 2019

I'd really need some help for the typings...

I'm one step further now:

declare function spected<ROOTINPUT, SPEC = Spec<ROOTINPUT>>(spec: SPEC, input: ROOTINPUT): Result<ROOTINPUT, SPEC>;

export type Predicate<INPUT, ROOTINPUT> = (value: INPUT, inputs: ROOTINPUT) => boolean;

export type ErrorMsg<INPUT> =
    | (string | number | boolean | symbol | null | undefined | object) // = anything that's not a function
    | ((value: INPUT, field: string) => any);

export type Spec<INPUT, ROOTINPUT = any> = Partial<{[key in keyof INPUT]: SpecValue<INPUT[key], ROOTINPUT>}>;

export type SpecValue<INPUT, ROOTINPUT = any> =
    | ReadonlyArray<[Predicate<INPUT, ROOTINPUT>, ErrorMsg<INPUT>]>
    | ((value: INPUT) => any) // HERE I NEED HELP
    | Spec<INPUT, ROOTINPUT>;

// This is much much much better, but still not finished:
// export type Result<INPUT, SPEC> = INPUT extends {[key: string]: infer U}
//     ? {[key in keyof INPUT]: Result<INPUT[key], any>}
//     : true | string[];

export type Result<INPUT, SPEC> = {[key in keyof INPUT]: true | any[] | Result<INPUT[key], any>}

export default spected;

Changes:

  • ErrorMsg had to be string or a function returning a string. Now it can be anything. (Because spected allows anything to be within the errors array. Which is good!)
  • Generics! These are the main generics:
    • ROOTINPUT which needs to be passed to the validation predicates: [[(val, rootInput) => val !== undefined, 'errmsg']]
    • INPUT is the "current" input value, depending on where in the object tree you currently are
    • SPEC which is the validationRules object (of Type Spec).

What's missing:

  • Typings for SpecValue functions. (see comment in the code above)
  • A better Result type

The Result type will become kinda complicated, I guess. The type of each result depends on the type of spec, as far as I know so far. HERE I NEED YOUR HELP!

  • If spec is an array: The result will be an array. Right or wrong?
  • If spec is an object: The result will be an object. Right or wrong?
  • If spec is a function: The result will be ... ???

@busypeoples
Copy link
Owner

Thanks for the great work @benneq!
Will finally have look tomorrow.

@busypeoples
Copy link
Owner

I will checkout your type definitions and add input to the missing parts.

@benneq
Copy link
Author

benneq commented Jan 17, 2019

The DefinitelyTyped repo doesn't include the latest version yet. So you may copy and paste the code from my comment above and use it.

There's a small problem with TypeScript's type inference for tuples. Though you should write your TypeScript code like this:

import spected, { Spec } from 'spected';

const data = {
  foo: {
    bar: 42
  }
}

const rules: Spec<typeof data, typeof data> = {
  foo: {
    bar: [[(value) => value > 9000, "error"]]
  }
}

const res = spected(rules, data);

because this does not work as expected:

spected({
  foo: {
    bar: [[(value) => value > 9000, "error"]] // it will recognize this as Array<Function | string> 
  }  // instead of [Function, string]
}, data);

@busypeoples
Copy link
Owner

Thanks for the info! I will use the above type definitions. Let's see how far we can get with this.

@benneq
Copy link
Author

benneq commented Jan 17, 2019

First let's try to get the SpecValue working. The only missing part should be the ((value: INPUT) => any) (here it needs something better than any)

After that, I should hopefully be able to finish the Result type.


Some further explanations:

  • spected is the spected function. It takes 2 args: 1. the spec (or rules), 2. the input (or data)
  • Spec is the validation spec for an object (it's getting reused for nested objects)
  • SpecValue are the different kinds of rules you can provide: 1. [[...]] stuff (= array of [Predicate, ErrorMsg] tuples), 2. () => ?? style, 3. nested object (= Spec)
  • Predicate is used within the [[...]]. The first part of the tuple is the predicate
  • ErrorMsg is also used within [[...]]. The seconds part of the tuple. It can be anything, but if it's a function, it receives 2 args: 1. input value, 2. field name

@busypeoples
Copy link
Owner

busypeoples commented Jan 17, 2019

Haven't tried it yet, but shouldn't this be the type?

((value: INPUT) =>  ReadonlyArray<[Predicate<INPUT, ROOTINPUT>, ErrorMsg<INPUT>]>

It should always return the predicates. Can you verify? I'm currently setting up TypeScript.

Correction:

((value: INPUT) =>  SpecValue<INPUT, ROOTINPUT = any>

It might return any of the three possible specValues.
Not sure if we can do recursive type definitions with TypeScript, but this could work with an interface?

@benneq
Copy link
Author

benneq commented Jan 17, 2019

I'll try that and will report back in a few minutes :)


EDIT: So it can be for example:

const rules = {
  foo: (values) => [[val => false, "error"]],
  bar: (values) => (values) => (values) => [[val => false, "error"]],
  baz: (values) => {
    x: [[val => false, "error"]],
    y: (yValues) => []
  }
}

Is that correct?


EDIT2: I still don't get it 😞

const data = {
  foo: 42
}

const rules = {
  foo: (value) => [[val => false, "error"]]
}

This is now allowed by TypeScript, but spected won't give any results for foo

Could you provide some simple examples without using ramda? 🤣
Then I can figure out the correct typings

@busypeoples
Copy link
Owner

I will check.

Also check this part, I think you need to wrap the object in () in your example.

baz: (values) => ({
    x: [[val => false, "error"]],
    y: (yValues) => []
  })

@benneq
Copy link
Author

benneq commented Jan 17, 2019

For objects: yes, true. I forgot that. This is working fine and gives the correct result:

const data = {
    foo: {
        bar: 42
    }
}

const rules = {
    foo: (value) => ({
        bar: [[val => false, "error"]]
    })
}

But what about this:

const data = {
    foo: 42
}

const rules = {
    foo: (value) => [[val => false, "error"]]
}

Should this do anything meaningful?


And when to use this:

const rules = {
    foo: (value) => (value) => (value) => ???
}

Sorry for being such a functional-currying-ramda noob

@busypeoples
Copy link
Owner

@benneq Thank you very much for the very valuable feedback. You shouldn't have to understand ramda to use the library. This is excellent feedback and shows some potential on how to improve the developer experience.

@busypeoples
Copy link
Owner

Regarding the ramda example from the beginning of this:

Not sure if a is valid, but b and c are the same, they expect an array of items and return an array of specifications for each item. Check this test https://github.com/25th-floor/spected/blob/master/test/index.js#L496

The input would be an array of users and the map function returns the rules for each user. I will rewrite the example for more clarification.

@busypeoples
Copy link
Owner

busypeoples commented Jan 17, 2019

This is what calling b or c in your example would return:

[
    {
        "firstName": [
            [
                ...,
                "Minimum firstName length of 6 is required."
            ]
        ],
        "lastName": [
            [
                 ...,
                "capital letter missing"
            ]
        ]
    },
    {
        "firstName": [
            [
                 ...,
                "Minimum firstName length of 6 is required."
            ]
        ],
        "lastName": [
            [
                 ...,
                "capital letter missing"
            ]
        ]
    },
    {
        "firstName": [
            [
                 ...,
                "Minimum firstName length of 6 is required."
            ]
        ],
        "lastName": [
            [
                ...,
                "capital letter missing"
            ]
        ]
    }
]

So, you could also write the following f.e.:

{users: 
[
    {
        "firstName": [
            [
                 ...,
                "Minimum firstName length of 6 is required."
            ]
        ],
        "lastName": [
            [
                ...,
                "capital letter missing"
            ]
        ]
    },
    {
        "firstName": [
            [
                 ...,
                "Minimum firstName length of 6 is required."
            ]
        ],
        "lastName": [
            [
                null,
                "capital letter missing"
            ]
        ]
    },
    {
        "firstName": [
            [
                 ...,
                "Minimum firstName length of 6 is required."
            ]
        ],
        "lastName": [
            [
                ...,
                "capital letter missing"
            ]
        ]
    }
]
}

or you could write it like this:

{
    b: input => input.map(val => [[(val) => false, 'msg']]),
    c: input => input.map(val => [[(val) => false, 'msg']]),
}

Does that help?

@benneq
Copy link
Author

benneq commented Jan 17, 2019

Ahh! That makes way more sense to me. Thank you!

So this is the same:

b: input => input.map(val => [[(val) => false, 'msg']])
c: Ramda.map([[(val) => false, 'msg']])

The signature is (value: INPUT) => ReadonlyArray<[Predicate<INPUT, ROOTINPUT>, ErrorMsg<INPUT>]>

And for objects it's:
(value: INPUT) => Spec<INPUT, ROOTINPUT>


The last option would be something like:

{
  x: input => somethingElse => ???
}

Is this possible (or does it have any meaning) at all for spected?

@busypeoples
Copy link
Owner

busypeoples commented Jan 17, 2019

I would neglect the last option, I don't think this could even work.
Not sure what the result would be, but you need a very specific structure to make it work.
The only valid use case for using a function in spected, is to validate against an unkown number of items, f.e. users, like in the example. If we can restrict any other possible options, this would be valuable actually.

@benneq
Copy link
Author

benneq commented Jan 17, 2019

Of course this can be restricted. TypeScript has an extremely mighty type system - in fact often overwhelming. You can have recursive structures, and you can have conditional types in generics, and lot's of other crazy stuff.

Maybe you should rethink this restriction (pointing to this: #106 (comment) )

@busypeoples
Copy link
Owner

This is the only other use case where this approach makes sense:

const data = {
  foo: {bar: 1}
};

const rules = {
  foo: (value) => {
     return {bar: [[val => false, "error"]]}
  }
};

verify(rules, data); // => { foo: {bar: "error"} 

@benneq
Copy link
Author

benneq commented Jan 17, 2019

And for:

const data = {
  foo: ["a", "b", "c"]
}

too?

@busypeoples
Copy link
Owner

const data = {
  foo: ["a", "b", "c"]
}

This is like the users example, sure.

@benneq
Copy link
Author

benneq commented Jan 17, 2019

Let's clarify:

if INPUT is Array then allow:
 [[...]]
 (val) => ...

if INPUT is Object then allow:
 [[...]]
 (val) => ...
 { ... }

else allow:
 [[...]]

@busypeoples
Copy link
Owner

busypeoples commented Jan 17, 2019

If the input is an object, we also have to return an object, if the input is an array, we have to return an array. The input/output have to match.

const data = {
  foo: {bar: 1}
};

const rules = {
  foo: (value) => {
     return {bar: [[val => false, "error"]]}
  }
};

const data2 = {
   users: [{id:1, name: "foo"}]
}

const rules2 = {
    users: input => input.map(val => [[(val) => false, 'msg']]),
}

Is the above definition helpful?

@benneq
Copy link
Author

benneq commented Jan 17, 2019

Thanks a lot! I guess that's it for the day... Now I have to see what TypeScript is capable of 😆 (or what I am capable of)

@busypeoples
Copy link
Owner

busypeoples commented Jan 17, 2019

@benneq But no stress. It doesn't have to be explicit for now.

@benneq
Copy link
Author

benneq commented Jan 17, 2019

But I want to use it as soon as possible with TypeScript ;)

@busypeoples
Copy link
Owner

busypeoples commented Jan 17, 2019

Sure, but you can define the type recursively for now, this will work.

((value: INPUT) =>  SpecValue<INPUT, ROOTINPUT>

Not tested, but this could be valid.

@benneq
Copy link
Author

benneq commented Jan 17, 2019

Yes this works. But again there are problems with type inference for tuples :(

const data = {
    b: ["a", "b"]
}
  
const rules: Spec<typeof data, typeof data> = {
    b: input => input.map(val => [[(val) => false, 'msg']])
    // here [[(val) => false, 'msg']] is not detected as tuple [Predicate, ErrorMsg],
    // but instead as Array<Predicate | ErrorMsg>
    // so you've got to write this:
    // input => input.map(val => [[(val) => false, 'msg']] as [Predicate<string, any>, ErrorMsg<string>][])
}

And the other problem is, that you are allowed to write (val) => (val) => (val) => ......

@benneq
Copy link
Author

benneq commented Jan 17, 2019

It's getting better and better!

I've renamed all the types and added some more:

  • SpecArray is [[...]]
  • SpecFunction is (value) => ...
    • If value is an array, it must return a SpecArray
    • Else it must return a SpecObject
  • SpecObject is { ... }
    • Each key must be a string
    • Each value is a SpecValue
  • SpecValue is either SpecArray, SpecFunction or SpecObject

I've removed the exports for Predicate and ErrorMsg.

The type inference for tuples seems now working correctly! No more need for as [...]

At the moment it is still allowed to write { x: (value) => ... } when x is neither array, nor object. But hopefully this this will work eventually when #104 and #106 are resolved 😃

Here are the newest typings:

declare function spected<ROOTINPUT, SPEC = SpecObject<ROOTINPUT, ROOTINPUT>>(spec: SPEC, input: ROOTINPUT): Result<ROOTINPUT, SPEC>;

type Predicate<INPUT, ROOTINPUT> = (value: INPUT, inputs: ROOTINPUT) => boolean;

type ErrorMsg<INPUT> =
    | (string | number | boolean | symbol | null | undefined | object)
    | ((value: INPUT, field: string) => any);

type SpecArrayElement<INPUT, ROOTINPUT> = [Predicate<INPUT, ROOTINPUT>, ErrorMsg<INPUT>];

export type SpecArray<INPUT, ROOTINPUT> = ReadonlyArray<SpecArrayElement<INPUT, ROOTINPUT>>;

export type SpecFunction<INPUT, ROOTINPUT> = INPUT extends ReadonlyArray<infer U>
    ? (value: INPUT) => ReadonlyArray<SpecArray<U, ROOTINPUT>>
    : (value: INPUT) => SpecObject<INPUT, ROOTINPUT>;

export type SpecObject<INPUT, ROOTINPUT> = Partial<{[key in keyof INPUT]: SpecValue<INPUT[key], ROOTINPUT>}>;

export type SpecValue<INPUT, ROOTINPUT> =
    | SpecArray<INPUT, ROOTINPUT>
    | SpecFunction<INPUT, ROOTINPUT>
    | SpecObject<INPUT, ROOTINPUT>;

export type Result<INPUT, SPEC> = {[key in keyof INPUT]: true | any[] | Result<INPUT[key], any>};

export default spected;

And some working example:

const res = spected(
  {
    // input's type is inferred as "string[]"
    // val's type is inferred as "string"
    b: (input) => [[[(val) => val === 'b', 'err']]],
    // input's type is inferred as "{ d: number }"
    // val's type is inferred as "number"
    c: (input) => ({ d: [[(val) => val > 9000, 'err']] }),
    // input's type is inferred as "number[]"
    // val's type is inferred as "number"
    d: input => input.map(val => [[(val) => val > 9000, 'msg']])
  },
  {
    a: 42,
    b: ["a", "b"],
    c: {
        d: 42
    },
    d: [9000, 9001]
  }
);

When using ramda, it won't correctly infer the types 😞 But then you still can use this:

const data = {
    b: ["a", "b"],
}
  
const rules: SpecObject<typeof data, typeof data> = {
    b: R.map(() => [[(val) => val === 'b', 'err']]),
}

const res = spected(rules, data);

Next step: The Result type


EDIT: Pushed the current typings. Still waiting for merge: DefinitelyTyped/DefinitelyTyped#32173

@benneq
Copy link
Author

benneq commented Jan 18, 2019

I just played some more with the typings. It's driving me nuts! 🤣

First: VSCode sometimes takes really long to refresh the typings in my code after I changed the spected typings. Before I figured that out, I have rewritten everything a hundred times, because there was always something wrong. Sometimes really strange type inference appeared, where value: number was suddenly value: ROOTINPUT extends {}. And I was just WTF ?!

I guess the recursive stuff is taking some time to compute.

Now I always wait a few more seconds, and then change the code a bit, (un)comment some lines, and THEN check the typings...

Second: TypeScript has some really strange behavior.

  1. Somehow booleans aren't recognized within the typings. (see code below)
  2. Somehow SpecArray isn't allowed to be ReadonlyArray

The Code:

Typings:

declare function spected<ROOTINPUT, SPEC extends SpecValue<ROOTINPUT, ROOTINPUT> = SpecValue<ROOTINPUT, ROOTINPUT>>(spec: SPEC, input: ROOTINPUT): Result<ROOTINPUT, SPEC>;

type Predicate<INPUT, ROOTINPUT> = (value: INPUT, inputs: ROOTINPUT) => boolean;

type ErrorMsg<INPUT> =
    | (string | number | boolean | symbol | null | undefined | object)
    | ((value: INPUT, field: string) => any);

export type Spec<INPUT, ROOTINPUT = any> = [Predicate<INPUT, ROOTINPUT>, ErrorMsg<INPUT>];

export type SpecArray<INPUT, ROOTINPUT = any> = Array<Spec<INPUT, ROOTINPUT>>

export type SpecFunction<INPUT, ROOTINPUT = any> = INPUT extends ReadonlyArray<infer U>
    ? (value: INPUT) => ReadonlyArray<SpecArray<U, ROOTINPUT>>
    : INPUT extends {[key: string]: any}
        ? (value: INPUT) => SpecObject<INPUT, ROOTINPUT>
        : (value: INPUT) => SpecArray<INPUT, ROOTINPUT>;

export type SpecObject<INPUT, ROOTINPUT = any> = Partial<{[key in keyof INPUT]: SpecValue<INPUT[key], ROOTINPUT>}>

export type SpecValue<INPUT, ROOTINPUT = any> = INPUT extends ReadonlyArray<any>
    ? SpecArray<INPUT, ROOTINPUT> | SpecFunction<INPUT, ROOTINPUT>
        : INPUT extends {[key: string]: any}
            ? SpecArray<INPUT, ROOTINPUT> | SpecFunction<INPUT, ROOTINPUT> | SpecObject<INPUT, ROOTINPUT>
            : SpecArray<INPUT, ROOTINPUT> | SpecFunction<INPUT, ROOTINPUT>;

export type Result<INPUT, SPEC> = {[key in keyof INPUT]: true | any[] | Result<INPUT[key], any>};

export default spected;

Tests:

const data = {
    notValidatedString: '',
    notValidatedArray: [0],
    notValidatedObject: {},
    str1: "",
    str2: "",
    number1: 0,
    number2: 0,
    boolean1: true,
    boolean2: true,
    array1: [0],
    array2: [0],
    array3: [0],
    emptyObj1: {},
    emptyObj2: {},
    emptyObj3: {},
    obj1: { foo: "bar" },
    obj2: { foo: "bar" },
    obj3: { foo: "bar" },
    obj4: { foo: "bar" },
    obj5: { foo: "bar" }
}

const res = spected<typeof data>(
    {
        str1: [[(value) => false, 'err']],
        str2: (value) => [[(value) => false, 'err']], // doesn't work until #104 #106
        number1: [[(value) => false, 'err']],
        number2: (value) => [[(value) => false, 'err']], // doesn't work until #104 #106
        // boolean1: [[(value) => false, 'err']], // value is of type any...
        // boolean2: (value) => [[(value) => false, 'err']], // value is of type any...
        array1: [[(value) => false, 'err']],
        array2: (value) => [[[(value) => false, 'err']]],
        array3: (value) => value.map(elem => [[(value) => false, 'err']]),
        emptyObj1: [[(value) => false, 'err']],
        emptyObj2: (value) => ({}),
        emptyObj3: {},
        obj1: [[(value) => false, 'err']],
        obj2: (value) => ({}),
        obj3: (value) => ({ foo: [[(value) => false, 'err']] }),
        obj4: {},
        obj5: { foo: [[(value) => false, 'err']] },
    },
    data
);

For the boolean issue, you can still write it like this and it works:

const data = {
    boolean1: true,
    boolean2: true
}

const res = spected<typeof data>(
    {
        boolean1: [[(value) => true, 'err']] as SpecArray<boolean, typeof data>,
        boolean2: ((value: boolean) => [[(value: boolean) => true, 'err']]) as SpecFunction<boolean, typeof data>,
    },
    data
);

Tests for the future (#104 #106) (already supported by the typings):

spected([[(value) => false, 'err']], "");
spected((value) => [[(value) => false, 'err']], "");
spected([[(value) => false, 'err']], 0);
spected((value) => [[(value) => false, 'err']], 0);
spected([[(value) => false, 'err']], true);
spected((value) => [[(value) => false, 'err']], true);
spected([[(value) => false, 'err']], [0]);
spected((value) => [[[(value) => false, 'err']]], [0]);
spected((value) => value.map(elem => [[(value) => false, 'err']]), [0]);
spected([[(value) => false, 'err']], {});
spected((value) => ({}), {});
spected({}, {});
spected([[(value) => false, 'err']], { foo: "bar" });
spected((value) => ({}), { foo: "bar" });
spected((value) => ({ foo: [[(value) => false, 'err']] }), { foo: "bar" });
spected({}, { foo: "bar" });
spected<{ foo: string }>({ foo: [[(value) => false, 'err']] }, { foo: "bar" }); // type inference not working
// must be explicitly provided

@benneq
Copy link
Author

benneq commented Jan 18, 2019

Thanks to jack-williams we now have working booleans! microsoft/TypeScript#29477

I didn't know about this "distributive conditional types" in TypeScript. Really weird stuff 😆

declare function spected<ROOTINPUT, SPEC extends SpecValue<ROOTINPUT, ROOTINPUT> = SpecValue<ROOTINPUT, ROOTINPUT>>(spec: SPEC, input: ROOTINPUT): Result<ROOTINPUT, SPEC>;

type Predicate<INPUT, ROOTINPUT> = (value: INPUT, inputs: ROOTINPUT) => boolean;

type ErrorMsg<INPUT> =
    | (string | number | boolean | symbol | null | undefined | object)
    | ((value: INPUT, field: string) => any);

export type Spec<INPUT, ROOTINPUT = any> = [Predicate<INPUT, ROOTINPUT>, ErrorMsg<INPUT>];

export type SpecArray<INPUT, ROOTINPUT = any> = Array<Spec<INPUT, ROOTINPUT>>

export type SpecFunction<INPUT, ROOTINPUT = any> = [INPUT] extends [ReadonlyArray<infer U>]
    ? (value: INPUT) => ReadonlyArray<SpecArray<U, ROOTINPUT>>
    : [INPUT] extends [object]
        ? (value: INPUT) => SpecObject<INPUT, ROOTINPUT>
        : (value: INPUT) => SpecArray<INPUT, ROOTINPUT>;

export type SpecObject<INPUT, ROOTINPUT = any> = Partial<{[key in keyof INPUT]: SpecValue<INPUT[key], ROOTINPUT>}>

export type SpecValue<INPUT, ROOTINPUT = any> = [INPUT] extends [ReadonlyArray<any>]
    ? SpecArray<INPUT, ROOTINPUT> | SpecFunction<INPUT, ROOTINPUT>
        : [INPUT] extends [object]
            ? SpecArray<INPUT, ROOTINPUT> | SpecFunction<INPUT, ROOTINPUT> | SpecObject<INPUT, ROOTINPUT>
            : SpecArray<INPUT, ROOTINPUT> | SpecFunction<INPUT, ROOTINPUT>;

export type Result<INPUT, SPEC> = {[key in keyof INPUT]: true | any[] | Result<INPUT[key], any>};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants