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

Allow configuration of unevaluatedProperties in JSON Schema emitter, and possibly change the default behaviour of all models. #3549

Closed
3 tasks done
adamscybot opened this issue Jun 7, 2024 · 5 comments
Assignees
Labels
1_0_E2E design:accepted Proposal for design has been discussed and accepted. emitter:json-schema emitter:openapi3 Issues for @typespec/openapi3 emitter feature New feature or request
Milestone

Comments

@adamscybot
Copy link

adamscybot commented Jun 7, 2024

Clear and concise description of the problem

Currently, there exists no (non-workaround) decorator in order to configure unevaluatedProperties on a schema. If looking to restrict unknown properties, I would hazard unevaluatedProperties: false is usually the tool that you should reach for as opposed to additionalProperties: false -- especially where you have TypeSpec in play which induces dev expectations/assumptions about semantics of things like extends.

That's because additionalProperties: false will restrict properties to those defined on the specific schema, and so prevent the use of properties on related schemas (via allOf for example).

In that sense additionalProperties: false seals the definition and would not actually work with any properties coming from some other model via extends, which is weird semantics.

Incidentally, there is no way to set additionalProperties: false either unless you override it with @extension("additionalProperties", Json<false>). But perhaps that should be added as a sort of @sealed decorator.

We could add a @noUnknown decorator or something that controls unevaluatedProperties . At the moment you have to do @extension("unevaluatedProperties", Json<false>) which feels like a hack.

Perhaps it could be put into one like @strictProperties(preventExtension: boolean) where preventExtension would be false by default. if false, it would add unevaluatedProperties: false to the schema. If true, it would instead use additionalProperties: false, which would ideally a throw an error if used when the schema attempts to use index types (including Record), or if that schema is extended by another.

Note, not being able to define these properties using a first class API is unusual as the default behaviour of JSON Schema is to allow additional properties which is behavior the user has not defined in their schema specifically using Record or index types. Therefore there is probably a debate to be had about if unevaluatedProperties: false should actually be added to schemas by default.

Checklist

  • Follow our Code of Conduct
  • Read the docs.
  • Check that there isn't already an issue that request the same feature to avoid creating a duplicate.
@adamscybot adamscybot changed the title Allow configuration of unevaluatedProperties in JSON Schema emitter, and maybe additionalProperties Add JSON Schema decorators for unevaluatedProperties & additionalProperties in JSON Schema emitter, and possibly change the default behavior Jun 7, 2024
@markcowl markcowl added feature New feature or request emitter:json-schema labels Jun 11, 2024
@markcowl markcowl added the design:proposed Proposal has been added and ready for discussion label Jun 11, 2024
@adamscybot
Copy link
Author

adamscybot commented Jun 13, 2024

Some other epiphanies about this.

  1. unevaluatedProperties: false is really just an instruction to not allow properties that are not on some allow list built up dynamically as a result of the "schema evaluation" step in whatever tool you are using. It is sort of "lazy" in nature and is checked after the schemas are "composed" in the wider eval process in real/theoretical tooling.
  2. On the flip side additionalProperties: false is something which changes/restricts the "schema evaluation" step behaviour itself and is a modifier that is being applied during the "composition" step. If a schema A has additionalProperties: false and a schema B does not then:
    • If schema A uses relationships like allOf and includes schema B, then any property present in B and not in A, won't actually allow it, unless they are redeclared.
    • If schema B uses relationships like allOf and includes schema A, then B effectively "adopts" the additionalProperties: false from A (since allof indicates valid against all schemas), effectively meaning any new properties in B that weren't on A, wont work.

In the context of typespec, (2) completely messes with the expected semantics of extends. So I think really, we should discourage additionalProperties: false to be configured (which I think is the status quo). If it was allowed, you'd want errors messaging if you try to extend from it or if you try to extend something else with this present in such a way as to lead to a situation where model properties can never practically be used. And it would only make sense as a decorator, not emitter-wide.

However (1) doesnt mess at all with these semantics. In fact, on both json schema and openapi emitter, there is a strong argument unevaluatedProperties should be by default "false":

  • It does not interfere with Record<> types that use additionalProperties: { ... } under the hood. Since its an instruction to check extra invalid properties later in the schema eval process.
  • Without this, there is odd behaviour that is undeclared in the type spec, where even if you didn't specify any ...Record<> or is Record<> type anywhere, the resulting schema is still valid with extra properties everywhere. This feels fundamentally unexpected. By setting unevaluatedProperties: false, you'd only get behaviour where extra properties are allowed everywhere if you specifically make use of Record<unknown>. And I think this matches the intended TypeSpec semantics and developer expectations.

You can imagine possibly this should be an emitter-level option that can override this default. As well as decorator overrides. But I think it makes sense to not have some implicit undeclared behaviour by default.

@adamscybot adamscybot changed the title Add JSON Schema decorators for unevaluatedProperties & additionalProperties in JSON Schema emitter, and possibly change the default behavior Add JSON Schema decorators for unevaluatedProperties in JSON Schema emitter, and possibly change the default behaviour Jun 13, 2024
@adamscybot adamscybot changed the title Add JSON Schema decorators for unevaluatedProperties in JSON Schema emitter, and possibly change the default behaviour Add decorators for unevaluatedProperties in JSON Schema emitter, and possibly change the default behaviour Jun 13, 2024
@adamscybot adamscybot changed the title Add decorators for unevaluatedProperties in JSON Schema emitter, and possibly change the default behaviour Allow configuration of unevaluatedProperties in JSON Schema emitter, and possibly change the default behaviour of all models. Jun 13, 2024
@markcowl markcowl added this to the Backlog milestone Nov 6, 2024
@markcowl markcowl modified the milestones: Backlog, [2025] January Dec 12, 2024
@markcowl markcowl removed this from the [2025] January milestone Jan 21, 2025
@cmars
Copy link

cmars commented Feb 8, 2025

In the context of typespec, (2) completely messes with the expected semantics of extends.

Indeed it would. It's kind of like a final keyword in that regard.

But why not allow models to opt-in to using it with a decorator if the OpenAPI output requires its use, taking responsibility for the potential consequences? Perhaps the allOf case can be caught with a linter rule, but even a warning that unconditionally says, "using @additionalProperties(false) might produce unsatisfiable composite types, use with caution".

In my situation, we have an API standard that requires additionalProperties: false for governance reasons on certain types; our OpenAPI is "compiled" by merging many individual microservice OpenAPI specs together, and some of these rules ensure they can merge effectively into a single API that looks like a single product.

It is very unfortunate that I cannot satisfy this requirement with Typespec. I can work around it by patching additionalProperties: false into these types as a post-processing step, but this also makes Typespec harder to use in my org.

It sounds like motivation behind #5393 may be similar.

@adamscybot
Copy link
Author

adamscybot commented Feb 9, 2025

This ticket was originally for the JSON schema emitter. For that emitter, unevaluatedProperties is probably the solution in isolation, but only if you are targeting JSON Schema 2019-09 or above.

Possibly, I should extend the title of this ticket since I do think it makes sense to think about this problem holistically across the targets instead of different tickets for each -- in the end solution could be per-emitter solutions, but we don't know that yet (I think...). And for sure part of the problem that is universal is around semantics in the language like Record models and extends etc and how they interact with various schema constructs.

I'm going to ping @wanlwanl and @chrisradek just because you interacted on related tickets (sorry for the annoying ping!) just in case they think the above is a good idea re: merging the tickets/expanding this one.


Anyway, back to it.

With other targets unevaluatedProperties doesn't always exist. For example, it does not exist in OpenAPI 3.0 but does in OpenAPI 3.1 as mentioned over here: #4321 (comment).

I think this kind of thing now comes down to a philosophical design decision on the part of TypeSpec. Are we trying to achieve "write it one consistent way and get a working solution regardless of target(s) schemas and their versions", or "you need to have the target in mind when writing your typespec".

I like TypeSpec, but I feel it already mixes both of these ideas, and it's sometimes unclear where the line is drawn. Yes -- emitters have their own decorators, and you have to explicitly commit to them.

But also, many of the core decorators as well as the language constructs themselves, just by existing, amount to assumptions about what is supported and makes sense across multiple emitters. But the reality is you've then got people on different versions of those targets as well. And the standards landscape is not just already-varied but also changing; and those changes could end up invalidating previous compromises on decisions like what is a global decorator and what is emitter level etc.

So should there be multiple emitters for each version of OpenAPI? Or should the versions of each target schema type be restricted such that it is with those that work with TypeSpec? Or add userland escape hatches? It's a complicated situation and, ultimately, the headache that TypeSpec aims to solve.

I've actually drove myself insane thinking about this problem and what the canonical answer is whilst working with TypeSpec, so I don't envy their position. Looking at it another way -- much of the compromises and decisions made around these things are actually in themselves, part of the inherent value of the TypeSpec project and their careful consideration of them is why we have TypeSpec today -- and its cool!

Personally, in this case, I don't think it would be wholly unreasonable (though maybe unpopular) to:

  • Add decorator support for unevaluatedProperties to JSON Schema & Open API emitters, or even turn it on by default (my preferred) but only for targets that support it. Have a way of declaring the target schema versions and validate against it. Since it works well with extends semantics it vibes well with TypeSpec and so this is a nice solution where everything fits.
  • Don't offer additionalProperties decorator for older targets, since it breaks TypeSpec fundamentals. Encourage people to upgrade their targets. But simultaneously offer an escape hatch to those who want to take the burden -- as you said.

In the short term, this allows everyone to do what they need without committing to potentially impactful and difficult-to-reverse decisions around additionalProperties, which could perhaps then be thought about in isolation and consequently moved forward or discarded depending on the outcome of whatever thought process takes place.

@chrisradek
Copy link
Member

We discussed this as a team and arrived at roughly this approach:

  1. Update @typespec/json-schema and the Open API 3.1 output of @typespec/openapi3 to use unevaluatedProperties instead of additionalProperties wherever additionalProperties is used. This also applies to the points below.
  2. Update @typespec/openapi3 to treat Record<never> as additionalProperties: { not: {} } - the equivalent of additionalProperties: false to match what @typespec/json-schema does today. This means something like model Foo { ...Record<never> } would output the schema { type: "object", additionalProperties: { not: {} } }.
  3. Update @typespec/openapi3/@typespec/json-schema emitters with an option to set additionalProperties: { not: {} } on models that don't already have additionalProperties defined. This may be limited to only being set on 'leaf' models (models with no derived models)

For item 2 - in order to handle the model extends case for Open API 3.0, we'll also copy properties from base models in the emitted schema as propertyName: {}. This workaround isn't needed for Open API 3.1 using unevaluatedProperties.

For Item 3 - we haven't found a good way to set additionalProperties: false on base models (models that are extended). We had a couple ideas but they each have some drawbacks:

  • In-line all base models that appear in allOf so we can set additionalProperties: false on the #/components/schemas version of the base model. We suspect this will break any tooling that tries to emit inheritance relationships.
  • Add all child properties to parent schemas, but then the children need to turn off properties from other children. This feels like a heavy kludge

@chrisradek chrisradek self-assigned this Feb 11, 2025
@chrisradek chrisradek added design:accepted Proposal for design has been discussed and accepted. and removed design:proposed Proposal has been added and ready for discussion labels Feb 11, 2025
@chrisradek chrisradek added this to the [2025] March milestone Feb 11, 2025
@cmars
Copy link

cmars commented Feb 12, 2025

Thank you so much for the thoughtful consideration of these use cases! I've not used the additionalProperties: { not: {} } form before, but if it's a functional enough substitute for false, we should be able to update our API linter to accept it. Will investigate further!

@chrisradek chrisradek added the emitter:openapi3 Issues for @typespec/openapi3 emitter label Feb 12, 2025
github-merge-queue bot pushed a commit that referenced this issue Feb 12, 2025
…roperties and support setting to false (#5961)

Related to #3549 

This PR does a couple things:

### Json Schema and Open API 3.1 use unevaluatedProperties instead of
additionalProperties

`unevaluatedProperties` is similar to `additionalProperties` in that it
can specify what extra properties are allowed on an object. One key
difference from additionalProperties though is that it evaluates
properties after any in-place applicators. Practically speaking, this
means that it will take into account any properties defined in `allOf`
subschemas when validating an object instance, whereas
additionalProperties only takes into account properties defined in its
containing schema.

This is particularly useful when trying to set `additionalProperties` to
false on a schema that has sub-schemas.

#### Risks

Functionally, I don't believe this is a breaking change. Where this
_might_ cause problems though is if someone is doing their own
processing of the Json Schema or Open API 3.1 output to add
`additionalProperties: false` if that field isn't present, since those
would now be called `unevaluatedProperties`.

### Open API 3 - support Record\<never\> for additionalProperties: {
not: {} }

This change brings the Open API 3 (3.0 and 3.1) emitter in line with the
Json Schema emitter, which already supports treating `Record<never>` as
`additionalProperties: { not: {} }`. `{ not: {} }` is equivalent to the
boolean `false` for schemas, so this is the same as supporting
`additionalProperties: false`.

_Note_: For Open API 3.1 output, `unevaluatedProperties` is emitted
instead of `additionalProperties`.

For Open API 3.0 output that still relies on `additionalProperties`,
there's some additional handling of `model extends` so that any
properties that exist on a base model, but not the derived model, are
redeclared as `propertyName: {}` in the derived model. Without this, if
the base model contains any properties that aren't in the derived model,
and the derived model spreads `Record<never>`, that emitted schema will
never pass validation on an input.

#### Example
```tsp
model Widget {
  id: string;
  ...Record<never>;
}
```
```json
{
  "type": "object",
  "required": [ "id" ],
  "properties": { "id": { "type": "string" } },
  "unevaluatedProperties": { "not": {} }
}
```

### Followups
A separate PR will be created to add an emitter option to
JsonSchema/OpenAPI emitters to automatically set `additionalProperties:
{ not: {} }` on at least leaf schemas.

---------

Co-authored-by: Christopher Radek <Christopher.Radek@microsoft.com>
Co-authored-by: Timothee Guerin <timothee.guerin@outlook.com>
dmnorc pushed a commit to dmnorc/typespec that referenced this issue Feb 18, 2025
…roperties and support setting to false (microsoft#5961)

Related to microsoft#3549 

This PR does a couple things:

### Json Schema and Open API 3.1 use unevaluatedProperties instead of
additionalProperties

`unevaluatedProperties` is similar to `additionalProperties` in that it
can specify what extra properties are allowed on an object. One key
difference from additionalProperties though is that it evaluates
properties after any in-place applicators. Practically speaking, this
means that it will take into account any properties defined in `allOf`
subschemas when validating an object instance, whereas
additionalProperties only takes into account properties defined in its
containing schema.

This is particularly useful when trying to set `additionalProperties` to
false on a schema that has sub-schemas.

#### Risks

Functionally, I don't believe this is a breaking change. Where this
_might_ cause problems though is if someone is doing their own
processing of the Json Schema or Open API 3.1 output to add
`additionalProperties: false` if that field isn't present, since those
would now be called `unevaluatedProperties`.

### Open API 3 - support Record\<never\> for additionalProperties: {
not: {} }

This change brings the Open API 3 (3.0 and 3.1) emitter in line with the
Json Schema emitter, which already supports treating `Record<never>` as
`additionalProperties: { not: {} }`. `{ not: {} }` is equivalent to the
boolean `false` for schemas, so this is the same as supporting
`additionalProperties: false`.

_Note_: For Open API 3.1 output, `unevaluatedProperties` is emitted
instead of `additionalProperties`.

For Open API 3.0 output that still relies on `additionalProperties`,
there's some additional handling of `model extends` so that any
properties that exist on a base model, but not the derived model, are
redeclared as `propertyName: {}` in the derived model. Without this, if
the base model contains any properties that aren't in the derived model,
and the derived model spreads `Record<never>`, that emitted schema will
never pass validation on an input.

#### Example
```tsp
model Widget {
  id: string;
  ...Record<never>;
}
```
```json
{
  "type": "object",
  "required": [ "id" ],
  "properties": { "id": { "type": "string" } },
  "unevaluatedProperties": { "not": {} }
}
```

### Followups
A separate PR will be created to add an emitter option to
JsonSchema/OpenAPI emitters to automatically set `additionalProperties:
{ not: {} }` on at least leaf schemas.

---------

Co-authored-by: Christopher Radek <Christopher.Radek@microsoft.com>
Co-authored-by: Timothee Guerin <timothee.guerin@outlook.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
1_0_E2E design:accepted Proposal for design has been discussed and accepted. emitter:json-schema emitter:openapi3 Issues for @typespec/openapi3 emitter feature New feature or request
Projects
None yet
Development

No branches or pull requests

5 participants