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

fix: API improvements aligning the types to our schemas #4650

Merged
merged 11 commits into from
Sep 12, 2023

Conversation

gastonfournier
Copy link
Contributor

Some of our types in OSS have drifted apart from our OpenAPI schemas. This will help them be aligned again

@vercel
Copy link

vercel bot commented Sep 8, 2023

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
unleash-docs ✅ Ready (Inspect) Visit Preview 💬 Add feedback Sep 12, 2023 1:34pm
unleash-monorepo-frontend ✅ Ready (Inspect) Visit Preview 💬 Add feedback Sep 12, 2023 1:34pm

@@ -238,7 +238,7 @@ class StrategyController extends Controller {
}

async createStrategy(
req: IAuthRequest<unknown, CreateStrategySchema>,
req: IAuthRequest<unknown, unknown, CreateStrategySchema>,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Body should be the third parameter

Comment on lines +407 to +411
// const data = await projectSchema.validateAsync(newProject);
export type CreateProject = Pick<IProject, 'id' | 'name'> & {
mode?: ProjectMode;
defaultStickiness?: string;
};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. Could we use OpenAPI to set defaults instead? It has a default property you can use. I think that's better than using Joi. Honestly, I was hoping we could get rid of Joi now that we've openapi'd everything. Having two layers of API validation seems excessive (not to mention it can get confusing when you're not sure where an error is coming from).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The challenge here is that the spec is in the enterprise repository. Based on our latest conversations, this logic should be moved to enterprise. So in theory, I think that's possible but I don't know how much effort it will be to move all project creation logic to enterprise, and then do proper unit testing to validate that joi and OpenAPI validate and use defaults in the same way.

So, I rather leave that for another day

@@ -6,12 +6,18 @@ export const createStrategySchema = {
description:
'The data required to create a strategy type. Refer to the docs on [custom strategy types](https://docs.getunleash.io/reference/custom-activation-strategies) for more information.',
required: ['name', 'parameters'],
additionalProperties: false,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important one so we have to declare all properties in the schema. This is input, so I'm not sure how specific we want to be with input data. Maybe we don't need this

@thomasheartman maybe you remember some decisions around additional properties. My take is that for input is not so important, as customers might want to extend Unleash, but I'm not 100% sure... I see in some cases we're doing it so should be fine:

export const createUserSchema = {
$id: '#/components/schemas/createUserSchema',
type: 'object',
additionalProperties: false,

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the discussion we had previously was:
always allow additional properties on inputs, never on outputs.

Following the old adage to be liberal in what you accept, but strict in what you return, we thought it best to allow any additional properties coming in. We didn't want to break a user's experience because they added a field that we don't recognize.

On the output front, we want to be very clear about what we expect and we want the types to be accurate. For that to be true, we needed conversions to fail if they contained extra data.


Based on that, I don't think we should have additionalProperties: false here. Did you do it for a reason?

On the other hand, one drawback of never using additionalProperties: false on input data, is that it makes creating union types harder (or almost impossible without a discriminator).

But that said, if we require a property to be supplied by the user, then we can use the required property.

Could you elaborate a bit on this? I'm not sure I understand.

Important one so we have to declare all properties in the schema.

Anyway, my suggestion is to remove this line.

Suggested change
additionalProperties: false,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did this to unveil the properties that were not declared in the spec. Turns out, this is not needed, but I discovered a way out of OpenAPI that allows us to define new properties without having to declare them in the spec.

Of course, this is not what we want, and I believe this was done by mistake or by not knowing that the type was backed by an OpenAPI spec. The trick is that this pattern also removes all safeguards from type safety.

The pattern (that we have to avoid) goes as follows:

  1. In the controller layer, receive the request body and pass it down to the service:
    async createStrategy(
        req: IAuthRequest<unknown, unknown, CreateStrategySchema>,
        res: Response<StrategySchema>,
    ): Promise<void> {
        const strategy = await this.strategyService.createStrategy(
            req.body,
            extractUsername(req),
        );
  1. In the service layer, add a different type which is defined not as an OpenAPI spec:
    async createStrategy(
        value: IMinimalStrategy, // this type
        userName: string,
    ): Promise<IStrategy> {
        const strategy = await strategySchema.validateAsync(value);

Now, because we allow additional properties on the first type, anything optional can be added to the second type without having to define the property in the OpenAPI spec (this doesn't apply to mandatory fields because all additional properties are inherently optional).

This leads to accidentally adding new optional parameters to internal types that do not get proper documentation in our spec.

What's the solution?
One possible solution is to avoid defining custom types and instead always refer to the schema type. Instead of:

export interface IMinimalStrategy {
    name: string;
    description?: string;
    editable?: boolean;
    parameters?: any[];
    title?: string;
}

We can use the schema and pick the fields we want from the schema:

export type IMinimalStrategy = Pick<
    CreateStrategySchema,
    'name' | 'description' | 'editable' | 'parameters' | 'title'
>;

This then sheds light on the missing fields at compile time:
image

Copy link
Contributor

@thomasheartman thomasheartman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! I want to figure out the discussion points before approving this, but it's looking very good 😄

@@ -6,12 +6,18 @@ export const createStrategySchema = {
description:
'The data required to create a strategy type. Refer to the docs on [custom strategy types](https://docs.getunleash.io/reference/custom-activation-strategies) for more information.',
required: ['name', 'parameters'],
additionalProperties: false,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the discussion we had previously was:
always allow additional properties on inputs, never on outputs.

Following the old adage to be liberal in what you accept, but strict in what you return, we thought it best to allow any additional properties coming in. We didn't want to break a user's experience because they added a field that we don't recognize.

On the output front, we want to be very clear about what we expect and we want the types to be accurate. For that to be true, we needed conversions to fail if they contained extra data.


Based on that, I don't think we should have additionalProperties: false here. Did you do it for a reason?

On the other hand, one drawback of never using additionalProperties: false on input data, is that it makes creating union types harder (or almost impossible without a discriminator).

But that said, if we require a property to be supplied by the user, then we can use the required property.

Could you elaborate a bit on this? I'm not sure I understand.

Important one so we have to declare all properties in the schema.

Anyway, my suggestion is to remove this line.

Suggested change
additionalProperties: false,

Comment on lines +407 to +411
// const data = await projectSchema.validateAsync(newProject);
export type CreateProject = Pick<IProject, 'id' | 'name'> & {
mode?: ProjectMode;
defaultStickiness?: string;
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. Could we use OpenAPI to set defaults instead? It has a default property you can use. I think that's better than using Joi. Honestly, I was hoping we could get rid of Joi now that we've openapi'd everything. Having two layers of API validation seems excessive (not to mention it can get confusing when you're not sure where an error is coming from).

Copy link
Contributor

@chriswk chriswk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems fair. I'd like to see us move away from the id for Permission and just use the name as the primary key, since we do require the name to be unique anyway. It can come as a separate PR though, so I'm inclined to give a 👍 on this.

@gastonfournier
Copy link
Contributor Author

I'd like to see us move away from the id for Permission and just use the name as the primary key

+1 maybe I can mark the type as deprecated

@gastonfournier gastonfournier merged commit c39d815 into main Sep 12, 2023
15 checks passed
@gastonfournier gastonfournier deleted the api-improvements branch September 12, 2023 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

None yet

3 participants