Skip to content

[AC-2278] [BEEEP] Typesafe Angular DI #8206

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

Merged
merged 34 commits into from
Mar 12, 2024
Merged

[AC-2278] [BEEEP] Typesafe Angular DI #8206

merged 34 commits into from
Mar 12, 2024

Conversation

eliykat
Copy link
Member

@eliykat eliykat commented Mar 5, 2024

Type of change

- [ ] Bug fix
- [ ] New feature development
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
- [ ] Build/deploy pipeline (DevOps)
- [ ] Other

Objective

A proposal for making our Angular DI configuration typesafe. I recommend checking this out locally and breaking stuff to convince yourself that it provides the safety I think it does.

The basic outline is:

  • instead of registering providers using object literals (e.g. {provide: myAbstractClass, useClass: myClass, deps: []}), which can quickly become out of date or incorrect, we now use factory functions that correspond to each method of registering a dependency: useClass, useValue, etc.
  • these factory functions are pass-through functions that perform type checking. For example, they ensure that the implementation actually implements the abstraction, and that the deps array matches what the implementation needs in its constructor.
  • to make people use these, the return type of the factory functions is SafeProvider. jslib-services.module now requires that the providers array be of this type, so you can no longer use object literals.

Code changes

  • file.ext: Description of what was changed and why

Screenshots

Before you submit

  • Please add unit tests where it makes sense to do so (encouraged but not required)
  • If this change requires a documentation update - notify the documentation team
  • If this change has particular deployment requirements - notify the DevOps team
  • Ensure that all UI additions follow WCAG AA requirements

@github-actions github-actions bot added the needs-qa Marks a PR as requiring QA approval label Mar 5, 2024
Copy link

codecov bot commented Mar 5, 2024

Codecov Report

Attention: Patch coverage is 43.75000% with 18 lines in your changes are missing coverage. Please review.

Project coverage is 25.70%. Comparing base (8f8385d) to head (dd750d4).

Files Patch % Lines
libs/angular/src/services/jslib-services.module.ts 0.00% 12 Missing ⚠️
apps/desktop/src/app/services/services.module.ts 0.00% 2 Missing ⚠️
...r/src/platform/services/theming/theming.service.ts 0.00% 2 Missing ⚠️
libs/angular/src/platform/utils/safe-provider.ts 0.00% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #8206   +/-   ##
=======================================
  Coverage   25.70%   25.70%           
=======================================
  Files        2267     2268    +1     
  Lines       66307    66310    +3     
  Branches    12454    12452    -2     
=======================================
+ Hits        17045    17046    +1     
- Misses      47912    47914    +2     
  Partials     1350     1350           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@bitwarden-bot
Copy link

bitwarden-bot commented Mar 5, 2024

Logo
Checkmarx One – Scan Summary & Details8b7b9ac9-1a16-4f94-92ef-d49f4a2982f4

New Issues

Severity Issue Source File / Package Checkmarx Insight
LOW Client_Password_In_Comment /libs/angular/src/platform/utils/safe-provider.ts: 83 Attack Vector

@eliykat eliykat changed the title [BEEEP] Typesafe DI [BEEEP] Typesafe Angular DI Mar 5, 2024
@eliykat eliykat removed the needs-qa Marks a PR as requiring QA approval label Mar 5, 2024
Copy link
Member

@MGibson1 MGibson1 left a comment

Choose a reason for hiding this comment

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

I think this is freaking awesome. The only downside is having to user factories for our provide objects, but the safety is totally worth that.

Copy link
Member Author

@eliykat eliykat Mar 8, 2024

Choose a reason for hiding this comment

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

This class used both injectable decorators (to inject its dependencies) and the providers array (to match the abstraction to the implementation). This is the only class that mixes these approaches and I think it's confusing. In any case, it couldn't be reconciled with this PR, so I've changed it to only use the providers array.

* of the same name. This ensures that your definition is typesafe.
* If you need help please ask for it, do NOT change the type of this array.
*/
const typesafeProviders: Array<SafeProvider | Constructor<any>> = [
Copy link
Member Author

@eliykat eliykat Mar 8, 2024

Choose a reason for hiding this comment

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

SafeProvider is defined as Opaque<Provider> in the helpers file. It's the type returned by all our helper functions, and it's an attempt to make people use them rather than continue to add object literals.

It's a bit awkward and I'm not entirely happy with it but I think it's the simplest solution compared to writing an eslint rule or something. However, any suggestions for how this can be improved are welcome - particularly so that other team members can understand what we're doing here.

Copy link
Member Author

@eliykat eliykat Mar 8, 2024

Choose a reason for hiding this comment

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

This file is reviewable if you use the "Hide whitespace" option in Github. And you should review this - I did have to change (fix) some dependencies. (My favourite is where we were injecting LoginService instead of LogService)

@eliykat eliykat marked this pull request as ready for review March 8, 2024 04:36
Copy link
Member Author

@eliykat eliykat left a comment

Choose a reason for hiding this comment

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

I've committed @MGibson1' contribution, which combines the factory functions into a single factory function (thanks Matt!), and I've made some further changes. The commit history is relatively clean if you prefer to review that way.

I moved this new work under Platform ownership - it fits their responsibilities and I also want a clear owner that people can bug with questions or change requests. (You're welcome ;) )

I can extend this pattern to our other services modules in a separate PR after this is merged.

Comment on lines +68 to +77
/**
* Represents a dependency provided with the useExisting option.
*/
type SafeExistingProvider<
A extends Constructor<any> | AbstractConstructor<any>,
I extends Constructor<InstanceType<A>> | AbstractConstructor<InstanceType<A>>,
> = {
provide: A;
useExisting: I;
};
Copy link
Member Author

Choose a reason for hiding this comment

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

I've tightened up the type check here by removing Partial.

This is because I decided that our current practice was wrong. We should register the Internal version of each service first, and then use useExisting to register the more restricted (non-internal) interface. That way we can guarantee that the implementation type extends the abstraction type in the useExisting declaration. See 10d4250

Copy link
Member

Choose a reason for hiding this comment

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

I agree that we were doing it backwards, but I think even better would be to, for example

safeProvider({
  provide: OrganizationService,
  useClass: OrganizationService,
  deps: [StateServiceAbstraction, StateProvider],
}),
safeProvider({
  provide: InternalOrganizationServiceAbstraction,
  useExisting: OrganizationService,
}),
safeProvider({
  provide: OrganizationServiceAbstraction,
  useExisting: OrganizationService,
}),

The hope here is that we don't actually need the InternalOrganizationService to extend OrganizationService so we could implement arbitrary interfaces and we can use the single class to provide a slew of non-interlocking interfaces.

Comment on lines +42 to +66
/**
* Represents a dependency provided with the useFactory option where a SafeInjectionToken is used as the token.
*/
type SafeFactoryProviderWithToken<
A extends SafeInjectionToken<any>,
I extends (...args: any) => InstanceType<SafeInjectionTokenType<A>>,
D extends MapParametersToDeps<Parameters<I>>,
> = {
provide: A;
useFactory: I;
deps: D;
};

/**
* Represents a dependency provided with the useFactory option where an abstract class is used as the token.
*/
type SafeFactoryProviderWithClass<
A extends AbstractConstructor<any>,
I extends (...args: any) => InstanceType<A>,
D extends MapParametersToDeps<Parameters<I>>,
> = {
provide: A;
useFactory: I;
deps: D;
};
Copy link
Member Author

Choose a reason for hiding this comment

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

I have split SafeFactoryProvider into 2 separate types, 1 where an InjectionToken is used, and 1 where an Abstract class is used. This means the types are kept dead simple with no conditional extends logic. The drawback is that we add yet more types to the safeProvider function. I thought this was an acceptable tradeoff but I'm open to other views here.

Copy link
Member

Choose a reason for hiding this comment

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

I'm fine with either. The only drawback is more generics on safeProvider and realistically that ship has sailed. If you ever need to specify types on that thing this isn't going to work.

* @param provider Your provider object in the usual shape (e.g. using useClass, useValue, useFactory, etc.)
* @returns The exact same object without modification (pass-through).
*/
export const safeProvider = <
Copy link
Member Author

Choose a reason for hiding this comment

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

Some of the types here are duplicates, but I think it's preferable to keep them clearly separated (if duplicative) so that they can easily be updated in step with any changes to the underlying types.

@eliykat eliykat changed the title [BEEEP] Typesafe Angular DI [AC-2278] [BEEEP] Typesafe Angular DI Mar 12, 2024
MGibson1
MGibson1 previously approved these changes Mar 12, 2024
Copy link
Member

@MGibson1 MGibson1 left a comment

Choose a reason for hiding this comment

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

I believe the two comments I have are blocking the alternate internal interface usage I described as prefferrable to me, but this is clearly an improvement and seems ready to go to me.

* Represents a dependency provided with the useFactory option where an abstract class is used as the token.
*/
type SafeFactoryProviderWithClass<
A extends AbstractConstructor<any>,
Copy link
Member

Choose a reason for hiding this comment

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

Isn't it valid to use a non-abstract constructor here, too?

* Represents a dependency provided with the useClass option.
*/
type SafeClassProvider<
A extends AbstractConstructor<any>,
Copy link
Member

Choose a reason for hiding this comment

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

Isn't it valid to use a non-abstract constructor here, too?

MGibson1
MGibson1 previously approved these changes Mar 12, 2024
@eliykat eliykat enabled auto-merge (squash) March 12, 2024 22:57
@eliykat eliykat requested a review from MGibson1 March 12, 2024 22:59
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

Successfully merging this pull request may close these issues.

3 participants