-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
feat: openapi spec contributor extension point #4258
Conversation
|
||
async main() {} | ||
|
||
async getSpecService() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The async
keyword is not needed here.
import {SPEC_SERVICE} from './keys'; | ||
import {SpecService} from './spec-contributor.service'; | ||
|
||
export class SpecComponent implements Component { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe it's overkill to have a SpecComponent
. We can register the SpecService
as part of RestComponent
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point agreed.
@@ -0,0 +1,12 @@ | |||
import {createBindingFromClass} from '@loopback/context'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's use a different directory name for extension-point
, which is generic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm, any suggestion? cuz I feel extension-point
is generic and spec-enhancer
is more specific
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe enhancers
* Find contributors for a given field | ||
* @param fieldName - The field name | ||
*/ | ||
async findContributors(): Promise<OAISpecContributor[] | undefined> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method can be removed to use this.getContributors()
directly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
right. created it for filtering by field, not needed any more.
*/ | ||
async generateSpec(options = {}): Promise<OpenApiSpec> { | ||
const contributors = await this.findContributors(); | ||
if (!contributors) return this._spec; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We'll get []
if no contributors are found.
get spec(): OpenApiSpec { | ||
return this._spec; | ||
} | ||
set spec(value: OpenApiSpec) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Copied from chat with @raymondfeng
when openapi spec is generated, we first build it from controller inspections, then call enhancers to update the spec
I add a setter for the _spec
here so people can pass in an existing spec to enhance by the service.
@@ -0,0 +1,23 @@ | |||
import {Application, createBindingFromClass} from '@loopback/core'; | |||
import {OAISpecEnhancerService, OAISPEC_ENHANCER_SERVICE} from '../../../../'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we use OAISpec
instead of OpenAPISpec
or OASpec
? What would the I
mean in the acronym? Isn't the I
part of the other acronym API
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, double checked with https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#introduction, I think a more accurate abbreviation should be OAS
, which stands for OpenAPI Specification.
I used OAI
because it's their github organization name.
beforeEach(givenAppWithSpecComponent); | ||
beforeEach(findSpecService); | ||
|
||
it('greets by language', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
greets by language
Need to change
@@ -0,0 +1,69 @@ | |||
// Copyright IBM Corp. 2019. All Rights Reserved. | |||
// Node module: @loopback/example-greeter-extension |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@loopback/example-greeter-extension
Need to change
title: 'LoopBack Application', | ||
version: '1.0.0', | ||
}, | ||
paths: {}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to set
servers: [{url: '/'}],
as well.
When we added the code to add security spec to the open api spec for this shopping example, we also had to set the servers url
to '/'
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please note not all root level fields are required in a complete OpenAPI spec, see its interface
Like servers: [{url: '/'}],
should be filled by our rest server component.
@jannyHou , great start :) |
packages/openapi-v3/package.json
Outdated
@@ -8,6 +8,7 @@ | |||
"dependencies": { | |||
"@loopback/context": "^1.24.0", | |||
"@loopback/repository-json-schema": "^1.11.2", | |||
"@loopback/core": "^1.11.0", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you need things from core
that are not part of context
, then please remove the dependency on @loopback/context
on L9 and rework imports to use @loopback/core
instead.
modifySpec(spec: OpenApiSpec) { | ||
spec.components = spec.components ?? {}; | ||
spec.components.securitySchemes = SECURITY_SCHEME_SPEC; | ||
return spec; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am little bit concerned about the design where the spec-enhancer is modifying the spec directly. Every implementation has to deal with detecting whether optional objects like spec.compontents
are set and be prepared to handle the case when they are not present.
Have you considered a different approach, where the enhancer provides new data to be added to the spec, and LB runtime ensures the data is cleverly merged together?
Take the InfoSpecEnhancer
for an example. IIUC, it will discard any info
fields provided by the app developer and replace them with info
object containing only title
and version
. This problem is easy to miss when reviewing the code. I would prefer to design spec-enhancing infrastructure in a way that will make errors like these more difficult to make.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have you considered a different approach, where the enhancer provides new data to be added to the spec, and LB runtime ensures the data is cleverly merged together?
Good question, I understand your concern and the risk to give enhancers the right to modify the entire spec, and actually for the original issue(add endpoint level security spec for auth automatically) we are addressing, WE CONTROL THE MERGING LOGIC is easier than my current approach in this PR.
While my concern is...for a fragment contributed by an arbitrary extension, how do we decide the merging logic?
I added a separate comment to explain how to add endpoint level security spec in #4258 (comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
An explanation for the comment above
Add endpoint level security spec as follow:
paths:
/something:
get:
security:
- basicAuth:[]
- Developer decorate endpoint as
@authenticate('basicAuth')
- Basic auth strategy component provide the security spec fragment and register the it as an OASEnhancer extension.(here the extension only provides the spec, but don't have merge logic)
- In the rest module, we first generate the entire
paths
spec for the controller methods with auth metadata included, e.g.x-auth-strategy-name: 'basicAuth'
. Then iterate through all the endpoints, retrieve the security spec fragment based on their auth metadata by callingOASenhancer.getSpecByName('basicAuth')
, merge fragment into the path spec.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While my concern is...for a fragment contributed by an arbitrary extension, how do we decide the merging logic?
I am proposing to start with the simplest option that works for security schemas. We can look into more flexible & powerful merging strategies later, as we learn about new different use cases.
I think the initial merging strategy can do the following:
- If the value is scalar (not object, not an array), then set it as-is.
- If the value is an object, then recursively merge all properties.
- If the value is an array, then append items provided by OASenhancer to the existing array.
A mock-up implementation:
function merge(current, enhancement) {
const result = _.cloneDeep(current);
for (key in enhancement) {
const val = enhancement[key];
if (Array.isArray(val)) {
// TODO: verify that current[key] is either `undefined` or an array
result[key] = [...current[key], ...val];
} else if (typeof val === 'object') {
// TODO: verify that current[key] is either `undefined` or an object
result[key] = merge(current[key] ?? {}, val);
} else {
result[key] = val;
}
}
return result;
}
Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, based on our chat, I see that I misunderstood this thread. My comment above is proposing a merging rule for security schemes in /components/security
, not for endpoint-level security requirements.
When it comes to security requirements at endpoint level, I feel it's important to have a somewhat generic tool provided by @loopback/rest
, a tool that any custom authentication solution can leverage.
This will make it easy for any extensions to contribute endpoint-level security requirements. If somebody decides to build an alternative to @loopback/authentication
extension, then they should have all tools available and should not need to re-implement contributions of endpoint security requirements.
The rest is just implementation details in a sense.
One option is to use the spec enhancer as proposed so far.
In https://github.com/strongloop/loopback-next/pull/4258/files#r357211992, I proposed to implement a helper function for merging OpenAPI spec fragments. If we decide to use the concept of OpenAPI spec enhancer as the tool for contributing endpoint security requirements, then I would like @loopback/rest
to expose a function for setting (or adding) a new security requirement for the given endpoints.
For example (assuming a hard-coded security spec to keep the example simpler):
import {assignSecurityRequirement, /*...*/} from '@loopback/rest';
@bind(asSpecEnhancer)
export class BasicAuthSpecEnhancer implements OAISpecEnhancer {
modifySpec(spec: OpenApiSpec) {
return assignSecurityRequirement({
spec, // the original spec
verb: 'get', // http verb
path: '/greet', // http path
name: 'petstore_auth', // name of the security schema
scopes: ['read:pets'], // optional, defaults to []
});
}
}
On the second thought, maybe we don't need any new function and assignSpecFragment
is actually all we need:
assignSpecFragment(spec, {
paths: {
[endpointPath]: {
[endpointVerb]: {
security: {
petstore_auth: [
'read:pets'
],
}
}
}
}
});
Another option is to introduce a new decorator. If you think about how we are defining endpoint metadata now, we have several decorators like @param
and @requestBody
that are contributing spec fragments that are later combined into a single operation object. I have an idea to implement a new decorator for defining security requirements.
Example usage:
import {get, security} from '@loopback/rest'
class GreetController {
@security('petstore_auth', [
"write:pets",
"read:pets",
])
@get('/hello')
greet() {
return 'hello world';
}
}
The @authenticate
decorator can then call @security
under the hood to contribute the correct security requirement to the OpenAPI spec.
import {get} from '@loopback/rest';
import {authenticate} from '@loopbakc/authenticate';
class GreetController {
@authenticate('basicAuth')
// ^-- will call security('basicAuth') under the hood
@get('/hello')
greet() {
return 'hello world';
}
}
Anyhow. I don't know if the @security
decorator is a viable solution that can support our current auth/auth design.
I am posting these ideas just for inspiration, please don't hold to them.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bajtos Good summary, based on all our discussions, I changed the implementation and design to be:
- I still kept the
modifySpec
function in the interface to give people the max flexibility to modify the original spec: - As we chatted in the meeting, I introduced a default spec merge function(merge a patch spec into the original spec), and exported it for extension developers to use in the
modifySpec
function.- It leverages an existing
json-merge-patch
module to merge the spec. I chose it becauseajv
module also uses it. More details can be found in the API documentation.
- It leverages an existing
- I added two more util functions to the service
getEnhancerByName
andapplyEnhancerByName
, so people can apply a single enhancer in case they need, or doesn't want to automatically apply all the enhancers ingenerateSpec
. - For the endpoint level security spec, I tend to agree with adding a new decorator
@security()
. Reasons are same as what you explained:- "When it comes to security requirements at endpoint level, I feel it's important to have a somewhat generic tool provided by @loopback/rest, a tool that any custom authentication solution can leverage."
* Generate OpenAPI spec from contributors | ||
*/ | ||
async generateSpec(options = {}): Promise<OpenApiSpec> { | ||
const contributors = await this.getContributors(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How are we going to decide in which order the contributors are applied? What if we have two contributors adding conflicting values for the same field in the spec - how is the user going to determine which contributor wins?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question * 2, I share your concern when implementing the extension point but haven't figured out a concrete plan, maybe use a combination of group and alphabet order? like how we load observers group by group in https://loopback.io/doc/en/lb4/Life-cycle.html#observer-groups
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moreover, if extension only contributes spec, and the app decides ALL the merge logic, then the order won't be a problem...
So the design here is related to our discussion in #4258 (comment).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe use a combination of group and alphabet order? like how we load observers group by group in https://loopback.io/doc/en/lb4/Life-cycle.html#observer-groups
Sounds good, I like the idea of using a mechanism we already have in place elsewhere in the framework. This way our users have less to learn.
I think it's ok to leave custom order out of scope of the initial implementation, as long as the design makes it possible to introduce custom order later.
How about using the concept of a fragment from GraphQL? Each contributor can provide a fragment of the spec to be merged with other fragments. I can imagine a name like |
version: '1.0.1', | ||
}; | ||
return spec; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would prefer the framework to provide a tool for merging OpenAPI spec fragments for us, so that enhancers don't have to (re)implement the logic.
@bind(asSpecEnhancer)
export class InfoSpecEnhancer implements OAISpecEnhancer {
modifySpec(spec: OpenApiSpec) {
return assignSpecFragment(spec, {
info: {
title: 'LoopBack Test Application',
version: '1.0.1',
},
});
}
}
aee8540
to
b859065
Compare
info: {title: 'LoopBack Test Application', version: '1.0.1'}, | ||
paths: {}, | ||
}; | ||
const mergedSpec = await specService.applyEnhancerByName('info'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why SECURITY_SCHEME_SPEC
is not part of the expected result? Does the enhancer only update the info
part? Cause I thought this line merges the security spec to it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why
SECURITY_SCHEME_SPEC
is not part of the expected result? Does the enhancer only update theinfo
part? Cause I thought (this line)[https://github.com//pull/4258/files#diff-dd8c9f9bac61f1da3ef811d81198fb9aR40] merges the security spec to it.
The service calls all contributors...including SecuritySpecEnhancer which applies the spec you are refering to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work, Janny.
:)
} | ||
|
||
/** | ||
* Generate OpenAPI spec by apply ALL registered enhancers |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
by applying
ALL
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jannyHou , please make the change I recommend above to
the existing comment:
* Generate OpenAPI spec by apply ALL registered enhancers
const enhancers = await this.getEnhancers(); | ||
if (_.isEmpty(enhancers)) return this._spec; | ||
for (const e of enhancers) { | ||
this._spec = e.modifySpec(this._spec); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why don't we call applyEnhancerByName
here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applyEnhancerByName()
calls getEnhancerByName()
, which loads all enhancers again by calling getEnhancers()
, see its implementation:
/**
* Find an enhancer by its name
* @param name The name of the enhancer you want to find
*/
async getEnhancerByName(name: string): Promise<OASEnhancer | undefined> {
// Get the latest list of enhancers
const enhancers = await this.getEnhancers();
return enhancers.find(e => e.name === name);
}
And applyEnhancerByName
essentially calls enhancer's modifySpec
function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I realize that. I just though we could make use of applyEnhancerByName from within the loop, but just noticed that the latter method performs a get on the enhancers and we won't want to be doing that twice. :) Looks good. disregard my question. :)
|
||
/** | ||
* The default merge function to patch the current OpenAPI spec. | ||
* It leverages module `json-merge-patch`'s merge API to merge two json object. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
two json objects.
*/ | ||
export const asSpecEnhancer: BindingTemplate = binding => { | ||
extensionFor(OAS_ENHANCER_EXTENSION_POINT_NAME)(binding); | ||
// is it ok to have a different namespace than the extension point name? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question. Raymond originally used the same value in both places when he created
this function to register authentication strategies. https://github.com/strongloop/loopback-next/blob/master/packages/authentication/src/types.ts#L91 .
export class SecuritySpecEnhancer implements OASEnhancer { | ||
name = 'security'; | ||
|
||
modifySpec(spec: OpenApiSpec): OpenApiSpec { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Security stuff can be added at the top level, or for operation level. Does this method take this into account?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The operation level spec will be addressed in a new story, see all the discussion in #4258 (comment), me and @bajtos tend to introduce @security
to provide the spec. So the extension in this PR only focus on merging the security scheme into components.
expect(mergedSpec).to.eql(EXPECTED_SPEC); | ||
}); | ||
|
||
function givenAppWithSpecComponent() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
by component here, do we mean a LB4 component, or a openApiSpec document with a 'component' section?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good catch, no component any more.
expect(specService.spec).to.eql(EXPECTED_SPEC); | ||
}); | ||
|
||
it('generateSpec - loads and create spec for ALL registered extensions', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
loads and creates spec
OR
loads and applies spec
Do we want to say create or apply? You have methods like ApplyEnhancer etc
@jannyHou, please update the Checklist section by checking the relevant checkboxes |
@jannyHou, are you planning to add docs as a follow up PR of the task? Thanks. |
@jannyHou I like the idea to separate |
Yep adding the doc :)
Will update after adding doc :)
Great 👍 I believe now team agrees on the plan. |
} | ||
|
||
/** | ||
* Generate OpenAPI spec by apply ALL registered enhancers |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jannyHou , please make the change I recommend above to
the existing comment:
* Generate OpenAPI spec by apply ALL registered enhancers
const enhancers = await this.getEnhancers(); | ||
if (_.isEmpty(enhancers)) return this._spec; | ||
for (const e of enhancers) { | ||
this._spec = e.modifySpec(this._spec); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I realize that. I just though we could make use of applyEnhancerByName from within the loop, but just noticed that the latter method performs a get on the enhancers and we won't want to be doing that twice. :) Looks good. disregard my question. :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, just a few comments.
## OAS Enhancer Service as Extension Point | ||
|
||
The OAS enhancer extension point is created in package `@loopback/openapi-v3`. | ||
It organizes the registered OAS enhancers, and as the first implementation, it |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does as the first implementation
mean?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@agnes512 we will have follow-up stories to improve this feature.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mostly LGTM just some nitpicks 👍
Edit: sorry I didn't see @agnes512's comments before submitting
packages/openapi-v3/src/__tests__/unit/enhancers/fixtures/application.ts
Show resolved
Hide resolved
* @param currentSpec The original spec | ||
* @param patchSpec The patch spec to be merged into the original spec | ||
*/ | ||
export function defaultMergeFn( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should rename it to something like patchOpenApiSpec
or mergeOpenApiSpec
. For a consumer, it is a bit strange to call defaultMergeFn(...)
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agreed, mergeOpenApiSpec
sounds better to me, will rename it.
2435327
to
327d0a1
Compare
327d0a1
to
6d60027
Compare
}; | ||
|
||
export const SECURITY_SCHEME_SPEC: SecuritySchemeObjects = { | ||
bearerAuth: { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@emonddr Nitpick: Would it make sense to use jwt
specifically rather than bearerAuth
to be more descriptive?
"jwt": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "jwt"
}
It would make the openapi security object more informative IMO as JWT's are nearly always used as bearer tokens where are the former can vary.
"security": [
{
"jwt": []
}
],
connect to #3854
Created a service as extension point for OpenAPI spec enhancers under folder
src/enhancers
.spec-enhancer.service.ts
types.ts
describes the extension interfacekeys.ts
gives the extensionpoint a keyAdded two extensions in test fixtures, they are also the demo example for extension contributors
Added unit test for service utility functions like
findEnhancerByName
,applyEnhancerByName
,generateSpec
Feedback addressed:
json-merge-patch
, it's also used by moduleajv
.findEnhancerByName
andapplyEnhancerByName
to allow people do the merge with a single enhancer, I will enable loading the enhancers by group names in a separate PR.TBD: apply the extensionpoint to rest and other related modules, create corresponding extensions like controller spec builder, security spec builder, etc...in follow-up stories.
TBD: load the enhancers by group names in a separate PR.
Checklist
👉 Read and sign the CLA (Contributor License Agreement) 👈
npm test
passes on your machinepackages/cli
were updatedexamples/*
were updated👉 Check out how to submit a PR 👈