Skip to content

fix: setValidatorCompiler with addSchema#5188

Merged
mcollina merged 5 commits intofastify:mainfrom
derammo:derammo_5178
Dec 26, 2023
Merged

fix: setValidatorCompiler with addSchema#5188
mcollina merged 5 commits intofastify:mainfrom
derammo:derammo_5178

Conversation

@derammo
Copy link
Contributor

@derammo derammo commented Dec 5, 2023

test that validation compiler is called if schemas registered, tests for issue 5178

expected to fail with current code:

npx tap test/schema-validation.test.js -g 'External AJV instance'

@github-actions github-actions bot added the test Issue or pr related to our testing infrastructure. label Dec 5, 2023
@derammo
Copy link
Contributor Author

derammo commented Dec 5, 2023

for #5178

@derammo derammo changed the title test validation compiler with schema test: validation compiler with schema Dec 5, 2023
@metcoder95
Copy link
Member

Nice! Good, now all the magic happens around here:

setupValidator (serverOptions) {
const isReady = this.validatorCompiler !== undefined && !this.addedSchemas
if (isReady) {
return
}
this.validatorCompiler = this.getValidatorBuilder()(this.schemaBucket.getSchemas(), serverOptions.ajv)
}

Here is where we set the Validator Compiler, which is the function you are testing in your test case.
We need to ensure it does respect the fact that a custom validator is already in place for the current SchemaController instance.

@derammo
Copy link
Contributor Author

derammo commented Dec 5, 2023

@metcoder95 correct. I was thinking it should just check this.isCustomSerializerCompiler this.isCustomValidatorCompiler and not use the ValidatorBuilder mechanism to create a validatorCompiler in that case. I just didn't feel confident to know if that matched your intent or perhaps matters elsewhere. Seems like it is a straight up bug though.

my analysis: The test cases unfortunately don't actually use any schema extensions and just succeed because the default validator works totally fine as long as you don't extend JSON schema itself. So that's why it never got detected that it does nothing in these cases.

Copy link
Member

@Eomm Eomm 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 to work:

    if (isReady) {
      return
    }
    this.validatorCompiler = this.validatorCompiler || this.getValidatorBuilder()(this.schemaBucket.getSchemas(), serverOptions.ajv)

It does:

do not create the validator compiler if you have it already

@derammo
Copy link
Contributor Author

derammo commented Dec 6, 2023

@Eomm yes it would make the problem go away, but I believe it also disables the functionality of recreating the validatorCompiler if schemas are added. Note the second part of the isReady expression. It clearly is trying to do something like 'recreate validator compiler if dirty.'

I could have fixed it by just checking the extra boolean I mentioned but I wasn't sure why standard ValidatorBuilder (essentially Factory<ValidatorCompiler>) and custom callback (also essentially Factory<ValidatorCompiler>) are handled separately to begin with. Maybe it is just merged code of two different origins and really they should have just been the same. I don't know to what degree you prefer cleaning up stuff like that versus only making minimal changes?

[edit: what I said was wrong. the boolean isCustomValidatorCompiler is apparently shared between using a custom builder or just using a custom compiler directly, so that isn't safe.]

@derammo
Copy link
Contributor Author

derammo commented Dec 6, 2023

Ok I will take a stab at developing a minimal fix, since now I am invested. Easier to discuss when we are looking at code...

@Eomm
Copy link
Member

Eomm commented Dec 6, 2023

the boolean isCustomValidatorCompiler

checking that boolean seems more correct 👍🏼

   if (isReady) {
      return
    }

this.validatorCompiler = this.isCustomValidatorCompiler ? this.validatorCompiler : this.getValidatorBuilder()(this.schemaBucket.getSchemas(), serverOptions.ajv)

I don't know to what degree you prefer cleaning up stuff like that versus only making minimal changes?

I would keep it minimal.
But in general I agree with your opinion: we have 2 different interfaces for the same thing and I will open a discussion to drop the old one. The schemaController should be preferred because it lets you do more than the setSchemaValidator only, but the latter is "easier" to use.

It clearly is trying to do something like 'recreate validator compiler if dirty.'

Correct, but when the user set a custom validator using setSchemaValidator it is up to the dep to deal with the external schemas. It is documented + the test adds the schema to the ajv instance because of it. The dirty flag is used only and only if the dev is using the schemaController.
Plus, I trust our test suite, so if it is green, we will not break anything 😄

@derammo
Copy link
Contributor Author

derammo commented Dec 6, 2023

I can remove the very verbose comment from the code after we discuss :)

@derammo
Copy link
Contributor Author

derammo commented Dec 6, 2023

Basically the ugly part is that one mechanism (schema controller) does have separate references to the factory (builder) and the compiler, and the other mechanism (setSchemaValidator) doesn't. So we can't actually recreate the compiler in that case, which is reflected in the now quite ugly conditional. But it is the truth...

@derammo
Copy link
Contributor Author

derammo commented Dec 6, 2023

But in general I agree with your opinion: we have 2 different interfaces for the same thing and I will open a discussion to drop the old one. The schemaController should be preferred because it lets you do more than the setSchemaValidator only, but the latter is "easier" to use.

For your discussion: as a new user of the framework, I would have much preferred if setSchemaValidator didn't exist or was marked deprecated and bad, because it is unsafe and trying to use it wasted a bunch of time. If I had found the other mechanism was there (schema controller opts) then I would have just used that. Unfortunately sample code and google hits made me find the old ("easy" but also dangerous) mechanism and I didn't know it.

@derammo derammo changed the title test: validation compiler with schema fix: setValidationCompiler with addSchema Dec 6, 2023
@derammo derammo changed the title fix: setValidationCompiler with addSchema fix: setValidatorCompiler with addSchema Dec 6, 2023
// WARNING: isCustomValidatorCompiler will be true if there is a custom builder OR if there is a custom compiler
// installed directly via setValidatorCompiler, leading to this messy condition. Compilers installed via
// setValidatorCompiler will not be invalidated and recreated if schemas are added.
const isReady = this.validatorCompiler !== undefined && (this.isCustomValidatorCompiler || !this.addedSchemas)
Copy link
Member

Choose a reason for hiding this comment

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

For your discussion: as a new user of the framework, I would have much preferred if setSchemaValidator didn't exist or was marked deprecated and bad, because it is unsafe and trying to use it wasted a bunch of time. If I had found the other mechanism was there (schema controller opts) then I would have just used that. Unfortunately sample code and google hits made me find the old ("easy" but also dangerous) mechanism and I didn't know it.

@Eomm is there any plan to drop this API in v5?

So far I believe it should be ok to do this; we just need to verify the risk is documented somewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Eomm this is resolved from my point of view since the "legacy" API no longer breaks after this fix, so I don't care if you deprecate it or not :) but it seems more like something for you to track.

i.e. I think this is for you to resolve.

@derammo
Copy link
Contributor Author

derammo commented Dec 6, 2023

Found a mistake in the change I made. It would also no longer recreate compilers if you use a custom builder. I will try again.

@derammo
Copy link
Contributor Author

derammo commented Dec 6, 2023

Here's a test from schema-serialization.test.js that succeeds in main but doesn't with a fix I was going to ~propose.

Could you please explain why the get request for /default is expected to return default? It seems the main fastify instance had the custom serializer installed and so it should absolutely return that?

[edit: never mind, figured it out; I did not understand register creates a new instance for the plugin, which has its own child schema controller]

test('Custom serializer per route', async t => {
  const fastify = Fastify()

  const outSchema = {
    $id: 'test',
    type: 'object',
    properties: {
      mean: { type: 'string' }
    }
  }

  fastify.get('/default', {
    handler (req, reply) { reply.send({ mean: 'default' }) },
    schema: { response: { 200: outSchema } }
  })

  let hit = 0
  fastify.register((instance, opts, done) => {
    instance.setSerializerCompiler(({ schema, method, url, httpStatus }) => {
...```
If anyone is available on the discord to take this offline, I am there as 'derammo'

@derammo
Copy link
Contributor Author

derammo commented Dec 6, 2023

ok new version is up, it is all green. I did not squash and rebase since there were discussions on the previous commits. Would you prefer I squash and rebase to have a single commit for discussion?

@metcoder95
Copy link
Member

ok new version is up, it is all green. I did not squash and rebase since there were discussions on the previous commits. Would you prefer I squash and rebase to have a single commit for discussion?

Sure, plain merge or rebase works

@derammo
Copy link
Contributor Author

derammo commented Dec 6, 2023

unrelated: are the unnamed (arrow) functions I introduced to make the compiler function into a builder causing the coverage check failures? do I need to tag them somehow as "these don't count?"

[update: yes that was the problem. I can resolve it by making them into something more disgusting like a bound named function, if you're ok with that. (see 7aa414a)]

Comment on lines +119 to +113
this.compilersFactory = Object.assign(
{},
this.compilersFactory,
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
this.compilersFactory = Object.assign(
{},
this.compilersFactory,
Object.assign(
this.compilersFactory,

Copy link
Contributor Author

@derammo derammo Dec 8, 2023

Choose a reason for hiding this comment

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

@Eomm In this context, this.compilersFactory is an alias to this.parent.compilersFactory. So using it as the first argument of Object.assign() will clobber the parent's compilersFactory. I only figured that out after trying the same thing and having the excellent test cases fail :)

Copy link
Member

Choose a reason for hiding this comment

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

It is also written 😂

Anyway: could you add a test named "the setValidatorCompiler overwrites the compilersFactory validator"?

I think we don't have this use case.

Then LGTM 👍🏼

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Eomm I wrote some tests. Can you please take a look? Do we agree the last one should succeed? (because it fails currently)

In the "backwards" case where the child instance has a custom builder, it does not work. It crashes out throwing that the extended schema is invalid, meaning

embedded.setSchemaController({ compilersFactory: { buildValidator: () => (routeSchemaDef) => ajv2.compile(routeSchemaDef.schema) } })
did nothing.

Copy link
Contributor Author

@derammo derammo Dec 10, 2023

Choose a reason for hiding this comment

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

PS: I changed the style of the tests a bit to actually USE custom schema instead of just testing that the compilers get called, because I felt at least some of the tests should make sure the resulting compiled schema validators are used at the right times.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

update: test case being discussed here is temporarily commented out

Copy link
Member

Choose a reason for hiding this comment

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

Can you please take a look? Do we agree the last one should succeed? (because it fails currently)

Yes and no 😄

The issue is that:

  • setValidatorCompiler is sync in the sense that it sets this.validatorCompiler when it runs
  • meanwhile setSchemaController is totally async and related to the applicationtion loadings, so I think they will fight for sure

To fix the test, I moved the setValidatorCompiler after the plugin registration, this ensures all the events are processed in order and the test passes.

I don't think we have a golden bullet here to make it work in a different way, because we can't execute buildValidator logic at that time, because the user may run an addSchema later!
It is a chicken and egg issue this one.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To fix the test, I moved the setValidatorCompiler after the plugin registration, this ensures all the events are processed in order and the test passes.

I don't think we have a golden bullet here to make it work in a different way, because we can't execute buildValidator logic at that time, because the user may run an addSchema later! It is a chicken and egg issue this one.

Yeah I also suspected it would be questionable what "correct" behavior would be. One thing I was considering is that the user's code order should decide. In other words, when you call setSchemaController, it should synchronously delete the installed custom compilers, so that they will be asynchronously recreated on the next setupValidator and setupSerializer. In other words, they would behave as if they synchronously compete, so the behavior matches the user's code order?

I just wasn't sure if that would work, so I figured we should discuss.

custom validator compilers installed directly via setValidatorCompiler
were removed on server start if any schema was previously added
via addSchema

new approach is to register any compiler from setValidatorCompiler
or setSerializerCompiler as a custom builder, so everything
is handled consistently

implementation note: schema controllers by default alias their
parent's compilers factory collection so you have to clone
it to make local changes

tests for 5178
tests for custom validator replacement
test for setSerializerCompiler with addSchema
@github-actions
Copy link

This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Dec 26, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

bugfix Issue or PR that should land as semver patch test Issue or pr related to our testing infrastructure.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

custom schema support installed by setValidatorCompiler removed if any schemas are added

4 participants