Skip to content

Commit b7a216e

Browse files
authored
Merge pull request #827 from bmish/no-restricted-service-injections
Add new rule `no-restricted-service-injections`
2 parents 641e313 + d462a81 commit b7a216e

File tree

7 files changed

+376
-2
lines changed

7 files changed

+376
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ Rules are grouped by category to help you understand their purpose. Each rule ha
151151
| | [named-functions-in-promises](./docs/rules/named-functions-in-promises.md) | enforce usage of named functions in promises |
152152
| :white_check_mark: | [no-incorrect-calls-with-inline-anonymous-functions](./docs/rules/no-incorrect-calls-with-inline-anonymous-functions.md) | disallow inline anonymous functions as arguments to `debounce`, `once`, and `scheduleOnce` |
153153
| :white_check_mark: | [no-invalid-debug-function-arguments](./docs/rules/no-invalid-debug-function-arguments.md) | disallow usages of Ember's `assert()` / `warn()` / `deprecate()` functions that have the arguments passed in the wrong order. |
154+
| | [no-restricted-service-injections](./docs/rules/no-restricted-service-injections.md) | disallow injecting certain services under certain paths |
154155

155156
### Routes
156157

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# no-restricted-service-injections
2+
3+
In some parts of your application, you may prefer to disallow certain services from being injected. This can be useful for:
4+
5+
* Deprecating services one folder at a time
6+
* Creating isolation between different parts of your application
7+
8+
## Rule Details
9+
10+
This rule disallows injecting specified services under specified paths.
11+
12+
## Examples
13+
14+
With this example configuration:
15+
16+
```json
17+
[
18+
"error",
19+
{
20+
"paths": ["folder1", "folder2", "folder3"],
21+
"services": ["deprecated-service"],
22+
"error": "Please stop using this service as it is in the process of being deprecated",
23+
},
24+
{
25+
"paths": ["isolated-folder"],
26+
"services": ["service-disallowed-for-use-in-isolated-folder"],
27+
},
28+
{
29+
"services": ["service-disallowed-anywhere"],
30+
},
31+
]
32+
```
33+
34+
This would be disallowed:
35+
36+
```js
37+
// folder1/my-component.js
38+
39+
class MyComponent extends Component {
40+
@service deprecatedService;
41+
}
42+
```
43+
44+
## Configuration
45+
46+
* object[] -- containing the following properties:
47+
* string[] -- `services` -- list of (kebab-case) service names that should be disallowed from being injected under the specified paths
48+
* string[] -- `paths` -- optional list of regexp file paths that injecting the specified services should be disallowed under (omit this field to match any path)
49+
* string -- `error` -- optional custom error message to display for violations
50+
51+
## Related Rules
52+
53+
* The [no-restricted-imports](https://eslint.org/docs/rules/no-restricted-imports) or [import/no-restricted-paths](https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-restricted-paths.md) rules are the JavaScript import statement equivalent of this rule.

lib/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ module.exports = {
4848
'no-proxies': require('./rules/no-proxies'),
4949
'no-replace-test-comments': require('./rules/no-replace-test-comments'),
5050
'no-restricted-resolver-tests': require('./rules/no-restricted-resolver-tests'),
51+
'no-restricted-service-injections': require('./rules/no-restricted-service-injections'),
5152
'no-side-effects': require('./rules/no-side-effects'),
5253
'no-test-and-then': require('./rules/no-test-and-then'),
5354
'no-test-import-export': require('./rules/no-test-import-export'),
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
'use strict';
2+
3+
const kebabCase = require('lodash.kebabcase');
4+
const assert = require('assert');
5+
const emberUtils = require('../utils/ember');
6+
7+
const DEFAULT_ERROR_MESSAGE = 'Injecting this service is not allowed from this file.';
8+
9+
module.exports = {
10+
meta: {
11+
type: 'suggestion',
12+
docs: {
13+
description: 'disallow injecting certain services under certain paths',
14+
category: 'Miscellaneous',
15+
url:
16+
'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/no-restricted-service-injections.md',
17+
},
18+
schema: {
19+
type: 'array',
20+
minItems: 1,
21+
items: [
22+
{
23+
type: 'object',
24+
required: ['services'],
25+
properties: {
26+
paths: {
27+
type: 'array',
28+
minItems: 1,
29+
items: {
30+
type: 'string',
31+
},
32+
},
33+
services: {
34+
type: 'array',
35+
minItems: 1,
36+
items: {
37+
type: 'string',
38+
},
39+
},
40+
error: {
41+
type: 'string',
42+
},
43+
},
44+
additionalProperties: false,
45+
},
46+
],
47+
},
48+
},
49+
50+
DEFAULT_ERROR_MESSAGE,
51+
52+
create(context) {
53+
// Validate options.
54+
context.options.forEach((option) =>
55+
option.services.forEach((service) =>
56+
assert(
57+
service.toLowerCase() === service,
58+
'Service name should be passed in kebab-case (all lower case)'
59+
)
60+
)
61+
);
62+
63+
// Find matching blacklist entries for this file path.
64+
const blacklists = context.options.filter(
65+
(option) => !option.paths || option.paths.some((path) => context.getFilename().match(path))
66+
);
67+
68+
if (blacklists.length === 0) {
69+
return {};
70+
}
71+
72+
function checkForViolationAndReport(node, serviceName) {
73+
const serviceNameKebabCase = serviceName.split('/').map(kebabCase).join('/'); // splitting is used to avoid converting folder/ to folder-
74+
75+
blacklists.forEach((blacklist) => {
76+
// Blacklist services are always passed in in kebab-case, so we can do a kebab-case comparison.
77+
if (blacklist.services.includes(serviceNameKebabCase)) {
78+
context.report({
79+
node,
80+
message: blacklist.error || DEFAULT_ERROR_MESSAGE,
81+
});
82+
}
83+
});
84+
}
85+
86+
return {
87+
// Handles:
88+
// * myService: service()
89+
// * propertyName: service('myService')
90+
Property(node) {
91+
if (!emberUtils.isInjectedServiceProp(node)) {
92+
return;
93+
}
94+
95+
const callExpression = node.value;
96+
97+
// Get the service name either from the string argument or from the property name.
98+
if (callExpression.arguments && callExpression.arguments.length >= 1) {
99+
if (
100+
callExpression.arguments[0].type === 'Literal' &&
101+
typeof callExpression.arguments[0].value === 'string'
102+
) {
103+
// The service name is the string argument.
104+
checkForViolationAndReport(node, callExpression.arguments[0].value);
105+
} else {
106+
// Ignore this case since the argument is not a string.
107+
}
108+
} else {
109+
// The service name is the property name.
110+
checkForViolationAndReport(node, node.key.name);
111+
}
112+
},
113+
114+
// Handles:
115+
// * @service myService
116+
// * @service() myService
117+
// * @service('myService') propertyName
118+
ClassProperty(node) {
119+
if (!emberUtils.isInjectedServiceProp(node)) {
120+
return;
121+
}
122+
123+
// Find the service decorator.
124+
const serviceDecorator =
125+
node.decorators &&
126+
node.decorators.find(
127+
(decorator) =>
128+
(decorator.expression.type === 'Identifier' &&
129+
decorator.expression.name === 'service') ||
130+
(decorator.expression.type === 'CallExpression' &&
131+
decorator.expression.callee.type === 'Identifier' &&
132+
decorator.expression.callee.name === 'service')
133+
);
134+
135+
// Get the service name either from the string argument or from the property name.
136+
const serviceName =
137+
serviceDecorator.expression.type === 'CallExpression' &&
138+
serviceDecorator.expression.arguments &&
139+
serviceDecorator.expression.arguments.length === 1 &&
140+
serviceDecorator.expression.arguments[0].type === 'Literal' &&
141+
typeof serviceDecorator.expression.arguments[0].value === 'string'
142+
? serviceDecorator.expression.arguments[0].value
143+
: node.key.name;
144+
145+
checkForViolationAndReport(node, serviceName);
146+
},
147+
};
148+
},
149+
};

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"dependencies": {
6565
"@ember-data/rfc395-data": "^0.0.4",
6666
"ember-rfc176-data": "^0.3.13",
67+
"lodash.kebabcase": "^4.1.1",
6768
"snake-case": "^3.0.3"
6869
},
6970
"changelog": {
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
const RuleTester = require('eslint').RuleTester;
2+
const rule = require('../../../lib/rules/no-restricted-service-injections');
3+
4+
const { DEFAULT_ERROR_MESSAGE } = rule;
5+
6+
const ruleTester = new RuleTester({
7+
parser: require.resolve('babel-eslint'),
8+
parserOptions: {
9+
ecmaVersion: 2015,
10+
sourceType: 'module',
11+
},
12+
});
13+
14+
ruleTester.run('no-restricted-service-injections', rule, {
15+
valid: [
16+
{
17+
// Service name doesn't match (with property name):
18+
code: 'Component.extend({ myService: service() })',
19+
options: [{ paths: ['app/components'], services: ['abc'] }],
20+
filename: 'app/components/path.js',
21+
},
22+
{
23+
// Service name doesn't match (with string argument):
24+
code: "Component.extend({ randomName: service('myService') })",
25+
options: [{ paths: ['app/components'], services: ['abc'] }],
26+
filename: 'app/components/path.js',
27+
},
28+
{
29+
// Service name doesn't match (with decorator)
30+
code: "class MyComponent extends Component { @service('myService') randomName }",
31+
options: [{ paths: ['app/components'], services: ['abc'] }],
32+
filename: 'app/components/path.js',
33+
},
34+
{
35+
// Service scope doesn't match:
36+
code: "Component.extend({ randomName: service('scope/myService') })",
37+
options: [{ paths: ['app/components'], services: ['my-service'] }],
38+
filename: 'app/components/path.js',
39+
},
40+
{
41+
// File path doesn't match:
42+
code: 'Component.extend({ myService: service() })',
43+
options: [{ paths: ['other/path'], services: ['my-service'] }],
44+
filename: 'app/components/path.js',
45+
},
46+
{
47+
// Not the service decorator:
48+
code: 'Component.extend({ myService: otherDecorator() })',
49+
options: [{ paths: ['app/components'], services: ['my-service'] }],
50+
filename: 'app/components/path.js',
51+
},
52+
{
53+
// Ignores injection due to dynamic variable usage:
54+
code: 'Component.extend({ myService: service(SOME_VARIABLE) })',
55+
options: [{ paths: ['app/components'], services: ['my-service'] }],
56+
filename: 'app/components/path.js',
57+
},
58+
],
59+
invalid: [
60+
{
61+
// Without service name argument:
62+
code: 'Component.extend({ myService: service() })',
63+
options: [{ paths: ['app/components'], services: ['my-service'] }],
64+
output: null,
65+
filename: 'app/components/path.js',
66+
errors: [{ message: DEFAULT_ERROR_MESSAGE, type: 'Property' }],
67+
},
68+
{
69+
// With camelized service name argument:
70+
code: "Component.extend({ randomName: service('myService') })",
71+
options: [{ paths: ['app/components'], services: ['my-service'] }],
72+
output: null,
73+
filename: 'app/components/path.js',
74+
errors: [{ message: DEFAULT_ERROR_MESSAGE, type: 'Property' }],
75+
},
76+
{
77+
// With dasherized service name argument:
78+
code: "Component.extend({ randomName: service('my-service') })",
79+
options: [{ paths: ['app/components'], services: ['my-service'] }],
80+
output: null,
81+
filename: 'app/components/path.js',
82+
errors: [{ message: DEFAULT_ERROR_MESSAGE, type: 'Property' }],
83+
},
84+
{
85+
// With nested, camelized service name:
86+
code: "Component.extend({ randomName: service('scope/myService') })",
87+
options: [{ paths: ['app/components'], services: ['scope/my-service'] }],
88+
output: null,
89+
filename: 'app/components/path.js',
90+
errors: [{ message: DEFAULT_ERROR_MESSAGE, type: 'Property' }],
91+
},
92+
{
93+
// With nested, dasherized service name:
94+
code: "Component.extend({ randomName: service('scope/my-service') })",
95+
options: [{ paths: ['app/components'], services: ['scope/my-service'] }],
96+
output: null,
97+
filename: 'app/components/path.js',
98+
errors: [{ message: DEFAULT_ERROR_MESSAGE, type: 'Property' }],
99+
},
100+
{
101+
// With decorator with camelized service name argument:
102+
code: "class MyComponent extends Component { @service('myService') randomName }",
103+
options: [{ paths: ['app/components'], services: ['my-service'] }],
104+
output: null,
105+
filename: 'app/components/path.js',
106+
errors: [{ message: DEFAULT_ERROR_MESSAGE, type: 'ClassProperty' }],
107+
},
108+
{
109+
// With decorator with dasherized service name argument:
110+
code: "class MyComponent extends Component { @service('my-service') randomName }",
111+
options: [{ paths: ['app/components'], services: ['my-service'] }],
112+
output: null,
113+
filename: 'app/components/path.js',
114+
errors: [{ message: DEFAULT_ERROR_MESSAGE, type: 'ClassProperty' }],
115+
},
116+
{
117+
// With decorator without service name argument (without parentheses):
118+
code: 'class MyComponent extends Component { @service myService }',
119+
options: [{ paths: ['app/components'], services: ['my-service'] }],
120+
output: null,
121+
filename: 'app/components/path.js',
122+
errors: [{ message: DEFAULT_ERROR_MESSAGE, type: 'ClassProperty' }],
123+
},
124+
{
125+
// With decorator without service name argument (with parentheses):
126+
code: 'class MyComponent extends Component { @service() myService }',
127+
options: [{ paths: ['app/components'], services: ['my-service'] }],
128+
output: null,
129+
filename: 'app/components/path.js',
130+
errors: [{ message: DEFAULT_ERROR_MESSAGE, type: 'ClassProperty' }],
131+
},
132+
{
133+
// With custom error message:
134+
code: 'Component.extend({ myService: service() })',
135+
options: [
136+
{
137+
paths: ['app/components'],
138+
services: ['my-service'],
139+
error: 'my-service is deprecated, please do not use it.',
140+
},
141+
],
142+
output: null,
143+
filename: 'app/components/path.js',
144+
errors: [{ message: 'my-service is deprecated, please do not use it.', type: 'Property' }],
145+
},
146+
{
147+
// With multiple violations:
148+
code: 'Component.extend({ myService: service() })',
149+
options: [
150+
{ paths: ['app/components'], services: ['my-service'], error: 'Error 1' },
151+
{ paths: ['app/components'], services: ['my-service'], error: 'Error 2' },
152+
],
153+
output: null,
154+
filename: 'app/components/path.js',
155+
errors: [
156+
{ message: 'Error 1', type: 'Property' },
157+
{ message: 'Error 2', type: 'Property' },
158+
],
159+
},
160+
{
161+
// Without specifying any paths (should match any path):
162+
code: 'Component.extend({ myService: service() })',
163+
options: [{ services: ['my-service'] }],
164+
output: null,
165+
filename: 'app/components/path.js',
166+
errors: [{ message: DEFAULT_ERROR_MESSAGE, type: 'Property' }],
167+
},
168+
],
169+
});

0 commit comments

Comments
 (0)