feat(core): Allow credential reuse on HTTP Request node (#3228)

*  Create controller

*  Mount controller

* ✏️ Add error messages

*  Create scopes fetcher

*  Account for non-existent credential type

* 📘 Type scopes request

*  Adjust error message

* 🧪 Add tests

*  Introduce simple node versioning

*  Add example how to read version in node-code for custom logic

* 🐛 Fix setting of parameters

* 🐛 Fix another instance where it sets the wrong parameter

*  Remove unnecessary TOODs

*  Re-version HTTP Request node

* 👕 Satisfy linter

*  Retrieve node version

*  Undo Jan's changes to Set node

* 🧪 Fix CI/CD for `/oauth2-credential` tests (#3230)

* 🐛 Fix notice warning missing background color (#3231)

* 🐛 Check for generic auth in node cred types

*  Refactor credentials dropdown for HTTP Request node (#3222)

*  Discoverability flow (#3229)

*  Added node credentials type proxy. Changed node credentials input order.

*  Add computed property from versioning branch

* 🐛 Fix cred ref lost and unsaved

*  Make options consistent with cred type names

*  Use prop to set component order

*  Use constant and version

*  Fix rendering for generic auth creds

*  Mark as required on first selection

*  Implement discoverability flow

*  Mark as required on subsequent selections

*  Fix marking as required after cred deletion

*  Refactor to clean up

*  Detect position automatically

*  Add i18n to option label

*  Hide subtitle for custom action

*  Detect active credential type

*  Prop drilling to re-render select

* 🔥 Remove unneeded property

* ✏️ Rename arg

* 🔥 Remove unused import

* 🔥 Remove unneeded getters

* 🔥 Remove unused import

*  Generalize cred component positioning

*  Set up request

* 🐛 Fix edge case in endpoint

*  Display scopes alert box

*  Revert "Generalize cred comp positioning"

This reverts commit 75eea89273.

*  Consolidate HTTPRN check

*  Fix hue percentage to degree

* 🔥 Remove unused import

* 🔥 Remove unused import

* 🔥 Remove unused class

* 🔥 Remove unused import

* 📘 Create type for HTTPRN v2 auth params

* ✏️ Rename check

* 🔥 Remove unused import

* ✏️ Add i18n to `reportUnsetCredential()`

*  Refactor Alex's spacing changes

*  Post-merge fixes

*  Add docs link

* 🔥 Exclude Notion OAuth cred

* ✏️ Update copy

* ✏️ Rename param

* 🎨 Reposition notice and simplify styling

* ✏️ Update copy

* ✏️ Update copy

*  Hide params during custom action

*  Show notice if any cred type supported

* 🐛 Prevent scopes text overflow

* 🔥 Remove superfluous check

* ✏️ Break up docstring

* 🎨 Tweak notice styling

*  Reorder cred param in Webhook node

* ✏️ Shorten cred name in scopes notice

* 🧪 Update Notice snapshots

* 🐛 Fix check when `globalRole` is `undefined`

*  Revert 3f2c4a6

*  Apply feedback from Product

* 🧪 Update snapshot

*  Adjust regex expansion pattern for singular

* 🔥 Remove unused import

* 🔥 Remove logging

*  Make `somethingElse` key more unique

*  Move something else to constants

*  Consolidate notice component

*  Apply latest feedback

* 🧪 Update tests

* 🧪 Update snapshot

* ✏️ Fix singular version

* 🧪 Finalize tests

* ✏️ Rename constant

* 🧪 Expand tests

* 🔥 Remove `truncate` prop

* 🚚 Move scopes fetching to store

* 🚚 Move method to component

*  Use constant

*  Refactor `Notice` component

* 🧪 Update tests

* 🔥 Remove unused keys

*  Inject custom API call option

* 🔥 Remove unused props

* 🎨 Use `compact` prop

* 🧪 Update snapshots

* 🚚 Move scopes to store

* 🚚 Move `nodeCredentialTypes` to parent

* ✏️ Rename cred types per branding

* 🐛 Clear scopes when none

*  Add default

* 🚚 Move `newHttpRequestNodeCredentialType` to parent

* 🔥 Remove test data

*  Separate lines for readability

*  Change reference from node to node name

* ✏️ Rename i18n keys

*  Refactor OAuth check

* 🔥 Remove unused key

* 🚚 Move `OAuth1/2 API` to i18n

*  Refactor `skipCheck`

*  Add `stopPropagation` and `preventDefault`

* 🚚 Move active credential scopes logic to store

* 🎨 Fix spacing for `NodeWebhooks` component

*  Implement feedback

*  Update HTTPRN default and issue copy

* Refactor to use `CredentialsSelect` param (#3304)

*  Refactor into cred type param

*  Componentize scopes notice

* 🔥 Remove unused data

* 🔥 Remove unused `loadOptions`

*  Componentize `NodeCredentialType`

* 🐛 Fix param validation

* 🔥 Remove dup methods

*  Refactor all references to `isHttpRequestNodeV2`

* 🎨 Fix styling

* 🔥 Remove unused import

* 🔥 Remove unused properties

* 🎨 Fix spacing for Pipedrive Trigger node

* 🎨 Undo Webhook node styling change

* 🔥 Remove unused style

*  Cover `httpHeaderAuth` edge case

* 🐛 Fix `this.node` reference

* 🚚 Rename to `credentialsSelect`

* 🐛 Fix mistaken renaming

*  Set one attribute per line

*  Move condition to instantiation site

* 🚚 Rename prop

*  Refactor away `prepareScopesNotice`

* ✏️ Rename i18n keys

* ✏️ Update i18n calls

* ✏️ Add more i18n keys

* 🔥 Remove unused props

* ✏️ Add explanatory comment

*  Adjust check in `hasProxyAuth`

*  Refactor `credentialSelected` from prop to event

*  Eventify `valueChanged`, `setFocus`, `onBlur`

*  Eventify `optionSelected`

*  Add `noDataExpression`

* 🔥 Remove logging

* 🔥 Remove URL from scopes

*  Disregard expressions for display

* 🎨 Use CSS modules

* 📘 Tigthen interface

* 🐛 Fix generic auth display

* 🐛 Fix generic auth validation

* 📘 Loosen type

* 🚚 Move event params to end

*  Generalize reference

*  Refactor generic auth as `credentialsSelect` param

*  Restore check for `httpHeaderAuth `

* 🚚 Rename `existing` to `predefined`

* Extend metrics for HTTP Request node (#3282)

*  Extend metrics

* 🧪 Add tests

*  Update param names

Co-authored-by: Alex Grozav <alex@grozav.com>

*  Update check per new branch

*  Include generic auth check

*  Adjust telemetry (#3359)

*  Filter credential types by label

Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
Co-authored-by: Alex Grozav <alex@grozav.com>
This commit is contained in:
Iván Ovejero 2022-05-24 11:36:19 +02:00 committed by GitHub
parent 0212d65dae
commit 336fc9e2a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1396 additions and 228 deletions

View file

@ -3,6 +3,7 @@ import {
ICredentialTypeData, ICredentialTypeData,
ICredentialTypes as ICredentialTypesInterface, ICredentialTypes as ICredentialTypesInterface,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { RESPONSE_ERROR_MESSAGES } from './constants';
class CredentialTypesClass implements ICredentialTypesInterface { class CredentialTypesClass implements ICredentialTypesInterface {
credentialTypes: ICredentialTypeData = {}; credentialTypes: ICredentialTypeData = {};
@ -16,7 +17,11 @@ class CredentialTypesClass implements ICredentialTypesInterface {
} }
getByName(credentialType: string): ICredentialType { getByName(credentialType: string): ICredentialType {
return this.credentialTypes[credentialType].type; try {
return this.credentialTypes[credentialType].type;
} catch (error) {
throw new Error(`${RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL}: ${credentialType}`);
}
} }
} }

View file

@ -1456,7 +1456,7 @@ class App {
if (defaultLocale === 'en') { if (defaultLocale === 'en') {
return nodeInfos.reduce<INodeTypeDescription[]>((acc, { name, version }) => { return nodeInfos.reduce<INodeTypeDescription[]>((acc, { name, version }) => {
const { description } = NodeTypes().getByNameAndVersion(name, version); const { description } = NodeTypes().getByNameAndVersion(name, version);
acc.push(description); acc.push(injectCustomApiCallOption(description));
return acc; return acc;
}, []); }, []);
} }
@ -1480,7 +1480,7 @@ class App {
// ignore - no translation exists at path // ignore - no translation exists at path
} }
nodeTypes.push(description); nodeTypes.push(injectCustomApiCallOption(description));
} }
const nodeTypes: INodeTypeDescription[] = []; const nodeTypes: INodeTypeDescription[] = [];
@ -3114,3 +3114,58 @@ async function getExecutionsCount(
return { count, estimated: false }; return { count, estimated: false };
} }
const CUSTOM_API_CALL_NAME = 'Custom API Call';
const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
/**
* Inject a `Custom API Call` option into `resource` and `operation`
* parameters in a node that supports proxy auth.
*/
function injectCustomApiCallOption(description: INodeTypeDescription) {
if (!supportsProxyAuth(description)) return description;
description.properties.forEach((p) => {
if (
['resource', 'operation'].includes(p.name) &&
Array.isArray(p.options) &&
p.options[p.options.length - 1].name !== CUSTOM_API_CALL_NAME
) {
p.options.push({
name: CUSTOM_API_CALL_NAME,
value: CUSTOM_API_CALL_KEY,
});
}
return p;
});
return description;
}
const credentialTypes = CredentialTypes();
/**
* Whether any of the node's credential types may be used to
* make a request from a node other than itself.
*/
function supportsProxyAuth(description: INodeTypeDescription) {
if (!description.credentials) return false;
return description.credentials.some(({ name }) => {
const credType = credentialTypes.getByName(name);
if (credType.authenticate !== undefined) return true;
return isOAuth(credType);
});
}
function isOAuth(credType: ICredentialType) {
return (
Array.isArray(credType.extends) &&
credType.extends.some((parentType) =>
['oAuth2Api', 'googleOAuth2Api', 'oAuth1Api'].includes(parentType),
)
);
}

View file

@ -244,9 +244,7 @@ export declare namespace OAuthRequest {
namespace OAuth2Credential { namespace OAuth2Credential {
type Auth = OAuth1Credential.Auth; type Auth = OAuth1Credential.Auth;
type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }> & { type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }>;
user?: User;
};
} }
} }

View file

@ -1,24 +1,14 @@
<template> <template>
<div :id="id" :class="classes" role="alert"> <div :id="id" :class="classes" role="alert" @click=onClick>
<div class="notice-content"> <div class="notice-content">
<n8n-text size="small"> <n8n-text size="small" :compact="true">
<slot> <slot>
<span <span
:class="expanded ? $style['expanded'] : $style['truncated']" :class="showFullContent ? $style['expanded'] : $style['truncated']"
:id="`${id}-content`" :id="`${id}-content`"
role="region" role="region"
v-html="sanitizedContent" v-html="sanitizeHtml(showFullContent ? fullContent : content)"
/> />
<span v-if="canTruncate">
<a
role="button"
:aria-controls="`${id}-content`"
:aria-expanded="canTruncate && !expanded ? 'false' : 'true'"
@click="toggleExpanded"
>
{{ t(expanded ? 'notice.showLess' : 'notice.showMore') }}
</a>
</span>
</slot> </slot>
</n8n-text> </n8n-text>
</div> </div>
@ -30,9 +20,7 @@ import Vue from 'vue';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import N8nText from "../../components/N8nText"; import N8nText from "../../components/N8nText";
import Locale from "../../mixins/locale"; import Locale from "../../mixins/locale";
import {uid} from "../../utils"; import { uid } from "../../utils";
const DEFAULT_TRUNCATION_MAX_LENGTH = 150;
export default Vue.extend({ export default Vue.extend({
name: 'n8n-notice', name: 'n8n-notice',
@ -49,15 +37,11 @@ export default Vue.extend({
type: String, type: String,
default: 'warning', default: 'warning',
}, },
truncateAt: {
type: Number,
default: 150,
},
truncate: {
type: Boolean,
default: false,
},
content: { content: {
required: true,
type: String,
},
fullContent: {
type: String, type: String,
default: '', default: '',
}, },
@ -67,7 +51,7 @@ export default Vue.extend({
}, },
data() { data() {
return { return {
expanded: false, showFullContent: false,
}; };
}, },
computed: { computed: {
@ -79,22 +63,32 @@ export default Vue.extend({
]; ];
}, },
canTruncate(): boolean { canTruncate(): boolean {
return this.truncate && this.content.length > this.truncateAt; return this.fullContent !== undefined;
},
truncatedContent(): string {
if (!this.canTruncate || this.expanded) {
return this.content;
}
return this.content.slice(0, this.truncateAt as number) + '...';
},
sanitizedContent(): string {
return sanitizeHtml(this.truncatedContent);
}, },
}, },
methods: { methods: {
toggleExpanded() { toggleExpanded() {
this.expanded = !this.expanded; this.showFullContent = !this.showFullContent;
},
sanitizeHtml(text: string): string {
return sanitizeHtml(
text, {
allowedAttributes: { a: ['data-key', 'href', 'target'] },
}
);
},
onClick(e) {
if (e.target.localName !== 'a') return;
if (e.target.dataset.key === 'show-less') {
e.stopPropagation();
e.preventDefault();
this.showFullContent = false;
} else if (this.canTruncate && e.target.dataset.key === 'toggle-expand') {
e.stopPropagation();
e.preventDefault();
this.showFullContent = !this.showFullContent;
}
}, },
}, },
}); });
@ -102,15 +96,17 @@ export default Vue.extend({
<style lang="scss" module> <style lang="scss" module>
.notice { .notice {
font-size: var(--font-size-2xs);
display: flex; display: flex;
color: var(--custom-font-black); color: var(--custom-font-black);
margin: 0; margin: var(--spacing-s) 0;
padding: var(--spacing-xs); padding: var(--spacing-2xs);
background-color: var(--background-color); background-color: var(--background-color);
border-width: 1px 1px 1px 7px; border-width: 1px 1px 1px 7px;
border-style: solid; border-style: solid;
border-color: var(--border-color); border-color: var(--border-color);
border-radius: var(--border-radius-small); border-radius: var(--border-radius-small);
line-height: var(--font-line-height-compact);
a { a {
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);

View file

@ -11,6 +11,7 @@ describe('components', () => {
slots: { slots: {
default: 'This is a notice.', default: 'This is a notice.',
}, },
stubs: ['n8n-text'],
}); });
expect(wrapper.html()).toMatchSnapshot(); expect(wrapper.html()).toMatchSnapshot();
}); });
@ -23,28 +24,31 @@ describe('components', () => {
id: 'notice', id: 'notice',
content: 'This is a notice.', content: 'This is a notice.',
}, },
stubs: ['n8n-text'],
}); });
expect(wrapper.html()).toMatchSnapshot(); expect(wrapper.html()).toMatchSnapshot();
}); });
it('should render html', () => { it('should render HTML', () => {
const wrapper = render(N8nNotice, { const wrapper = render(N8nNotice, {
props: { props: {
id: 'notice', id: 'notice',
content: '<strong>Hello world!</strong> This is a notice.', content: '<strong>Hello world!</strong> This is a notice.',
}, },
stubs: ['n8n-text'],
}); });
expect(wrapper.container.querySelectorAll('strong')).toHaveLength(1); expect(wrapper.container.querySelectorAll('strong')).toHaveLength(1);
expect(wrapper.html()).toMatchSnapshot(); expect(wrapper.html()).toMatchSnapshot();
}); });
it('should sanitize rendered html', () => { it('should sanitize rendered HTML', () => {
const wrapper = render(N8nNotice, { const wrapper = render(N8nNotice, {
props: { props: {
id: 'notice', id: 'notice',
content: '<script>alert(1);</script> This is a notice.', content: '<script>alert(1);</script> This is a notice.',
}, },
stubs: ['n8n-text'],
}); });
expect(wrapper.container.querySelector('script')).not.toBeTruthy(); expect(wrapper.container.querySelector('script')).not.toBeTruthy();
@ -52,44 +56,5 @@ describe('components', () => {
}); });
}); });
}); });
describe('truncation', () => {
it('should truncate content longer than 150 characters', async () => {
const wrapper = render(N8nNotice, {
props: {
id: 'notice',
truncate: true,
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
},
});
const button = await wrapper.findByRole('button');
const region = await wrapper.findByRole('region');
expect(button).toBeVisible();
expect(button).toHaveTextContent('Show more');
expect(region).toBeVisible();
expect(region.textContent!.endsWith('...')).toBeTruthy();
});
it('should expand truncated text when clicking show more', async () => {
const wrapper = render(N8nNotice, {
props: {
id: 'notice',
truncate: true,
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
},
});
const button = await wrapper.findByRole('button');
const region = await wrapper.findByRole('region');
await fireEvent.click(button);
expect(button).toHaveTextContent('Show less');
expect(region.textContent!.endsWith('...')).not.toBeTruthy();
});
});
}); });
}); });

View file

@ -1,28 +1,33 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`components > N8nNotice > props > content > should render HTML 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_kaqw5_1 _warning_kaqw5_18\\">
<div class=\\"notice-content\\">
<n8n-text-stub size=\\"small\\" compact=\\"true\\" tag=\\"span\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_kaqw5_43\\"><strong>Hello world!</strong> This is a notice.</span></n8n-text-stub>
</div>
</div>"
`;
exports[`components > N8nNotice > props > content > should render correctly with content prop 1`] = ` exports[`components > N8nNotice > props > content > should render correctly with content prop 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_4m4il_1 _warning_4m4il_16\\"> "<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_kaqw5_1 _warning_kaqw5_18\\">
<div class=\\"notice-content\\"><span class=\\"_size-small_9dlpz_24 _regular_9dlpz_5\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_4m4il_41\\">This is a notice.</span> <div class=\\"notice-content\\">
<!----></span></div> <n8n-text-stub size=\\"small\\" compact=\\"true\\" tag=\\"span\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_kaqw5_43\\">This is a notice.</span></n8n-text-stub>
</div>
</div>" </div>"
`; `;
exports[`components > N8nNotice > props > content > should render html 1`] = ` exports[`components > N8nNotice > props > content > should sanitize rendered HTML 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_4m4il_1 _warning_4m4il_16\\"> "<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_kaqw5_1 _warning_kaqw5_18\\">
<div class=\\"notice-content\\"><span class=\\"_size-small_9dlpz_24 _regular_9dlpz_5\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_4m4il_41\\"><strong>Hello world!</strong> This is a notice.</span> <div class=\\"notice-content\\">
<!----></span></div> <n8n-text-stub size=\\"small\\" compact=\\"true\\" tag=\\"span\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_kaqw5_43\\"> This is a notice.</span></n8n-text-stub>
</div>" </div>
`;
exports[`components > N8nNotice > props > content > should sanitize rendered html 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_4m4il_1 _warning_4m4il_16\\">
<div class=\\"notice-content\\"><span class=\\"_size-small_9dlpz_24 _regular_9dlpz_5\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_4m4il_41\\"> This is a notice.</span>
<!----></span></div>
</div>" </div>"
`; `;
exports[`components > N8nNotice > should render correctly 1`] = ` exports[`components > N8nNotice > should render correctly 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_4m4il_1 _warning_4m4il_16\\"> "<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_kaqw5_1 _warning_kaqw5_18\\">
<div class=\\"notice-content\\"><span class=\\"_size-small_9dlpz_24 _regular_9dlpz_5\\">This is a notice.</span></div> <div class=\\"notice-content\\">
<n8n-text-stub size=\\"small\\" compact=\\"true\\" tag=\\"span\\">This is a notice.</n8n-text-stub>
</div>
</div>" </div>"
`; `;

View file

@ -120,7 +120,7 @@
var(--color-warning-tint-1-l) var(--color-warning-tint-1-l)
); );
--color-warning-tint-2-h: 34%; --color-warning-tint-2-h: 34;
--color-warning-tint-2-s: 80%; --color-warning-tint-2-s: 80%;
--color-warning-tint-2-l: 96%; --color-warning-tint-2-l: 96%;
--color-warning-tint-2: hsl( --color-warning-tint-2: hsl(

View file

@ -159,6 +159,9 @@ export interface IExternalHooks {
run(eventName: string, metadata?: IDataObject): Promise<void>; run(eventName: string, metadata?: IDataObject): Promise<void>;
} }
/**
* @deprecated Do not add methods to this interface.
*/
export interface IRestApi { export interface IRestApi {
getActiveWorkflows(): Promise<string[]>; getActiveWorkflows(): Promise<string[]>;
getActivationError(id: string): Promise<IActivationError | undefined >; getActivationError(id: string): Promise<IActivationError | undefined >;

View file

@ -0,0 +1,153 @@
<template>
<div>
<div :class="$style['parameter-value-container']">
<n8n-select
:size="inputSize"
filterable
:value="displayValue"
:placeholder="parameter.placeholder ? getPlaceholder() : $locale.baseText('parameterInput.select')"
:title="displayTitle"
@change="(value) => $emit('valueChanged', value)"
@keydown.stop
@focus="$emit('setFocus')"
@blur="$emit('onBlur')"
>
<n8n-option
v-for="credType in supportedCredentialTypes"
:value="credType.name"
:key="credType.name"
:label="credType.displayName"
>
<div class="list-option">
<div class="option-headline">
{{ credType.displayName }}
</div>
<div
v-if="credType.description"
class="option-description"
v-html="credType.description"
/>
</div>
</n8n-option>
</n8n-select>
<slot name="issues-and-options" />
</div>
<scopes-notice
v-if="scopes.length > 0"
:activeCredentialType="activeCredentialType"
:scopes="scopes"
/>
<div>
<node-credentials
:node="node"
:overrideCredType="node.parameters[parameter.name]"
@credentialSelected="(updateInformation) => $emit('credentialSelected', updateInformation)"
/>
</div>
</div>
</template>
<script lang="ts">
import { ICredentialType } from 'n8n-workflow';
import Vue from 'vue';
import { mapGetters } from 'vuex';
import ScopesNotice from '@/components/ScopesNotice.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
export default Vue.extend({
name: 'CredentialsSelect',
components: {
ScopesNotice,
NodeCredentials,
},
props: [
'activeCredentialType',
'node',
'parameter',
'inputSize',
'displayValue',
'isReadOnly',
'displayTitle',
],
computed: {
...mapGetters('credentials', ['allCredentialTypes', 'getScopesByCredentialType']),
scopes(): string[] {
if (!this.activeCredentialType) return [];
return this.getScopesByCredentialType(this.activeCredentialType);
},
supportedCredentialTypes(): ICredentialType[] {
return this.allCredentialTypes.filter((c: ICredentialType) => this.isSupported(c.name));
},
},
methods: {
/**
* Check if a credential type belongs to one of the supported sets defined
* in the `credentialTypes` key in a `credentialsSelect` parameter
*/
isSupported(name: string): boolean {
const supported = this.getSupportedSets(this.parameter.credentialTypes);
const checkedCredType = this.$store.getters['credentials/getCredentialTypeByName'](name);
for (const property of supported.has) {
if (checkedCredType[property] !== undefined) {
// edge case: `httpHeaderAuth` has `authenticate` auth but belongs to generic auth
if (name === 'httpHeaderAuth' && property === 'authenticate') continue;
return true;
}
}
if (
checkedCredType.extends &&
checkedCredType.extends.some(
(parentType: string) => supported.extends.includes(parentType),
)
) {
return true;
}
if (checkedCredType.extends && supported.extends.length) {
// recurse upward until base credential type
// e.g. microsoftDynamicsOAuth2Api -> microsoftOAuth2Api -> oAuth2Api
return checkedCredType.extends.reduce(
(acc: boolean, parentType: string) => acc || this.isSupported(parentType),
false,
);
}
return false;
},
getSupportedSets(credentialTypes: string[]) {
return credentialTypes.reduce<{ extends: string[]; has: string[] }>((acc, cur) => {
const _extends = cur.split('extends:');
if (_extends.length === 2) {
acc.extends.push(_extends[1]);
return acc;
}
const _has = cur.split('has:');
if (_has.length === 2) {
acc.has.push(_has[1]);
return acc;
}
return acc;
}, { extends: [], has: [] });
},
},
});
</script>
<style module lang="scss">
.parameter-value-container {
display: flex;
align-items: center;
}
</style>

View file

@ -75,7 +75,7 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { WAIT_TIME_UNLIMITED } from '@/constants'; import { CUSTOM_API_CALL_KEY, WAIT_TIME_UNLIMITED } from '@/constants';
import { externalHooks } from '@/components/mixins/externalHooks'; import { externalHooks } from '@/components/mixins/externalHooks';
import { nodeBase } from '@/components/mixins/nodeBase'; import { nodeBase } from '@/components/mixins/nodeBase';
import { nodeHelpers } from '@/components/mixins/nodeHelpers'; import { nodeHelpers } from '@/components/mixins/nodeHelpers';
@ -336,7 +336,11 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
}, },
methods: { methods: {
setSubtitle() { setSubtitle() {
this.nodeSubtitle = this.getNodeSubtitle(this.data, this.nodeType, this.getWorkflow()) || ''; const nodeSubtitle = this.getNodeSubtitle(this.data, this.nodeType, this.getWorkflow()) || '';
this.nodeSubtitle = nodeSubtitle.includes(CUSTOM_API_CALL_KEY)
? ''
: nodeSubtitle;
}, },
disableNode () { disableNode () {
this.disableNodes([this.data]); this.disableNodes([this.data]);

View file

@ -1,5 +1,5 @@
<template> <template>
<div v-if="credentialTypesNodeDescriptionDisplayed.length" :class="$style.container"> <div v-if="credentialTypesNodeDescriptionDisplayed.length" :class="['node-credentials', $style.container]">
<div v-for="credentialTypeDescription in credentialTypesNodeDescriptionDisplayed" :key="credentialTypeDescription.name"> <div v-for="credentialTypeDescription in credentialTypesNodeDescriptionDisplayed" :key="credentialTypeDescription.name">
<n8n-input-label <n8n-input-label
:label="$locale.baseText( :label="$locale.baseText(
@ -11,15 +11,20 @@
} }
)" )"
:bold="false" :bold="false"
size="small"
:set="issues = getIssues(credentialTypeDescription.name)" :set="issues = getIssues(credentialTypeDescription.name)"
size="small"
> >
<div v-if="isReadOnly"> <div v-if="isReadOnly">
<n8n-input disabled :value="selected && selected[credentialTypeDescription.name] && selected[credentialTypeDescription.name].name" size="small" /> <n8n-input
:value="selected && selected[credentialTypeDescription.name] && selected[credentialTypeDescription.name].name"
disabled
size="small"
/>
</div> </div>
<div
<div :class="issues.length ? $style.hasIssues : $style.input" v-else > v-else
:class="issues.length ? $style.hasIssues : $style.input"
>
<n8n-select :value="getSelectedId(credentialTypeDescription.name)" @change="(value) => onCredentialSelected(credentialTypeDescription.name, value)" :placeholder="$locale.baseText('nodeCredentials.selectCredential')" size="small"> <n8n-select :value="getSelectedId(credentialTypeDescription.name)" @change="(value) => onCredentialSelected(credentialTypeDescription.name, value)" :placeholder="$locale.baseText('nodeCredentials.selectCredential')" size="small">
<n8n-option <n8n-option
v-for="(item) in credentialOptions[credentialTypeDescription.name]" v-for="(item) in credentialOptions[credentialTypeDescription.name]"
@ -82,6 +87,7 @@ export default mixins(
name: 'NodeCredentials', name: 'NodeCredentials',
props: [ props: [
'node', // INodeUi 'node', // INodeUi
'overrideCredType', // cred type
], ],
data () { data () {
return { return {
@ -92,6 +98,7 @@ export default mixins(
computed: { computed: {
...mapGetters('credentials', { ...mapGetters('credentials', {
credentialOptions: 'allCredentialsByType', credentialOptions: 'allCredentialsByType',
getCredentialTypeByName: 'getCredentialTypeByName',
}), }),
credentialTypesNode (): string[] { credentialTypesNode (): string[] {
return this.credentialTypesNodeDescription return this.credentialTypesNodeDescription
@ -106,6 +113,10 @@ export default mixins(
credentialTypesNodeDescription (): INodeCredentialDescription[] { credentialTypesNodeDescription (): INodeCredentialDescription[] {
const node = this.node as INodeUi; const node = this.node as INodeUi;
const credType = this.getCredentialTypeByName(this.overrideCredType);
if (credType) return [credType];
const activeNodeType = this.$store.getters.nodeType(node.type, node.typeVersion) as INodeTypeDescription | null; const activeNodeType = this.$store.getters.nodeType(node.type, node.typeVersion) as INodeTypeDescription | null;
if (activeNodeType && activeNodeType.credentials) { if (activeNodeType && activeNodeType.credentials) {
return activeNodeType.credentials; return activeNodeType.credentials;
@ -198,7 +209,15 @@ export default mixins(
return; return;
} }
this.$telemetry.track('User selected credential from node modal', { credential_type: credentialType, workflow_id: this.$store.getters.workflowId }); this.$telemetry.track(
'User selected credential from node modal',
{
credential_type: credentialType,
node_type: this.node.type,
...(this.hasProxyAuth(this.node) ? { is_service_specific: true } : {}),
workflow_id: this.$store.getters.workflowId,
},
);
const selectedCredentials = this.$store.getters['credentials/getCredentialById'](credentialId); const selectedCredentials = this.$store.getters['credentials/getCredentialById'](credentialId);
const oldCredentials = this.node.credentials && this.node.credentials[credentialType] ? this.node.credentials[credentialType] : {}; const oldCredentials = this.node.credentials && this.node.credentials[credentialType] ? this.node.credentials[credentialType] : {};
@ -295,11 +314,7 @@ export default mixins(
<style lang="scss" module> <style lang="scss" module>
.container { .container {
margin: var(--spacing-xs) 0; margin-top: var(--spacing-xs);
> * {
margin-bottom: var(--spacing-xs);
}
} }
.warning { .warning {

View file

@ -23,14 +23,35 @@
</div> </div>
<div class="node-parameters-wrapper" v-if="node && nodeValid"> <div class="node-parameters-wrapper" v-if="node && nodeValid">
<div v-show="openPanel === 'params'"> <div v-show="openPanel === 'params'">
<node-credentials :node="node" @credentialSelected="credentialSelected"></node-credentials> <node-webhooks
<node-webhooks :node="node" :nodeType="nodeType" /> :node="node"
<parameter-input-list :parameters="parametersNoneSetting" :hideDelete="true" :nodeValues="nodeValues" path="parameters" @valueChanged="valueChanged" /> :nodeType="nodeType"
/>
<parameter-input-list
:parameters="parametersNoneSetting"
:hideDelete="true"
:nodeValues="nodeValues" path="parameters" @valueChanged="valueChanged"
>
<node-credentials
:node="node"
@credentialSelected="credentialSelected"
/>
</parameter-input-list>
<div v-if="parametersNoneSetting.length === 0" class="no-parameters"> <div v-if="parametersNoneSetting.length === 0" class="no-parameters">
<n8n-text> <n8n-text>
{{ $locale.baseText('nodeSettings.thisNodeDoesNotHaveAnyParameters') }} {{ $locale.baseText('nodeSettings.thisNodeDoesNotHaveAnyParameters') }}
</n8n-text> </n8n-text>
</div> </div>
<div v-if="isCustomApiCallSelected(nodeValues)" class="parameter-item parameter-notice">
<n8n-notice
:content="$locale.baseText(
'nodeSettings.useTheHttpRequestNode',
{ interpolate: { nodeTypeDisplayName: nodeType.displayName } }
)"
/>
</div>
</div> </div>
<div v-show="openPanel === 'settings'"> <div v-show="openPanel === 'settings'">
<parameter-input-list :parameters="nodeSettings" :hideDelete="true" :nodeValues="nodeValues" path="" @valueChanged="valueChanged" /> <parameter-input-list :parameters="nodeSettings" :hideDelete="true" :nodeValues="nodeValues" path="" @valueChanged="valueChanged" />

View file

@ -104,12 +104,44 @@
:placeholder="parameter.placeholder" :placeholder="parameter.placeholder"
/> />
<credentials-select
v-else-if="parameter.type === 'credentialsSelect' || (parameter.name === 'genericAuthType')"
ref="inputField"
:parameter="parameter"
:node="node"
:activeCredentialType="activeCredentialType"
:inputSize="inputSize"
:displayValue="displayValue"
:isReadOnly="isReadOnly"
:displayTitle="displayTitle"
@credentialSelected="credentialSelected"
@valueChanged="valueChanged"
@setFocus="setFocus"
@onBlur="onBlur"
>
<template v-slot:issues-and-options>
<parameter-issues
:issues="getIssues"
/>
<parameter-options
v-if="displayOptionsComputed"
:displayOptionsComputed="displayOptionsComputed"
:parameter="parameter"
:isValueExpression="isValueExpression"
:isDefault="isDefault"
:hasRemoteMethod="hasRemoteMethod"
@optionSelected="optionSelected"
/>
</template>
</credentials-select>
<n8n-select <n8n-select
v-else-if="parameter.type === 'options'" v-else-if="parameter.type === 'options'"
ref="inputField" ref="inputField"
:size="inputSize" :size="inputSize"
filterable filterable
:value="displayValue" :value="displayValue"
:placeholder="parameter.placeholder ? getPlaceholder() : $locale.baseText('parameterInput.select')"
:loading="remoteParameterOptionsLoading" :loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading" :disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle" :title="displayTitle"
@ -168,26 +200,21 @@
/> />
</div> </div>
<div class="parameter-issues" v-if="getIssues.length"> <parameter-issues
<n8n-tooltip placement="top" > v-if="parameter.type !== 'credentialsSelect'"
<div slot="content" v-html="`${$locale.baseText('parameterInput.issues')}:<br />&nbsp;&nbsp;- ` + getIssues.join('<br />&nbsp;&nbsp;- ')"></div> :issues="getIssues"
<font-awesome-icon icon="exclamation-triangle" /> />
</n8n-tooltip>
</div> <parameter-options
v-if="displayOptionsComputed && parameter.type !== 'credentialsSelect'"
:displayOptionsComputed="displayOptionsComputed"
:parameter="parameter"
:isValueExpression="isValueExpression"
:isDefault="isDefault"
:hasRemoteMethod="hasRemoteMethod"
@optionSelected="optionSelected"
/>
<div class="parameter-options" v-if="displayOptionsComputed">
<el-dropdown trigger="click" @command="optionSelected" size="mini">
<span class="el-dropdown-link">
<font-awesome-icon icon="cogs" class="reset-icon clickable" :title="$locale.baseText('parameterInput.parameterOptions')"/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="addExpression" v-if="parameter.noDataExpression !== true && !isValueExpression">{{ $locale.baseText('parameterInput.addExpression') }}</el-dropdown-item>
<el-dropdown-item command="removeExpression" v-if="parameter.noDataExpression !== true && isValueExpression">{{ $locale.baseText('parameterInput.removeExpression') }}</el-dropdown-item>
<el-dropdown-item command="refreshOptions" v-if="hasRemoteMethod">{{ $locale.baseText('parameterInput.refreshList') }}</el-dropdown-item>
<el-dropdown-item command="resetValue" :disabled="isDefault" :divided="!parameter.noDataExpression || hasRemoteMethod">{{ $locale.baseText('parameterInput.resetValue') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div> </div>
</template> </template>
@ -196,6 +223,7 @@ import { get } from 'lodash';
import { import {
INodeUi, INodeUi,
INodeUpdatePropertiesInformation,
} from '@/Interface'; } from '@/Interface';
import { import {
NodeHelpers, NodeHelpers,
@ -208,7 +236,12 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
import CodeEdit from '@/components/CodeEdit.vue'; import CodeEdit from '@/components/CodeEdit.vue';
import CredentialsSelect from '@/components/CredentialsSelect.vue';
import ExpressionEdit from '@/components/ExpressionEdit.vue'; import ExpressionEdit from '@/components/ExpressionEdit.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
import ScopesNotice from '@/components/ScopesNotice.vue';
import ParameterOptions from '@/components/ParameterOptions.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
// @ts-ignore // @ts-ignore
import PrismEditor from 'vue-prism-editor'; import PrismEditor from 'vue-prism-editor';
import TextEdit from '@/components/TextEdit.vue'; import TextEdit from '@/components/TextEdit.vue';
@ -218,6 +251,8 @@ import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers'; import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { CUSTOM_API_CALL_KEY } from '@/constants';
import { mapGetters } from 'vuex';
export default mixins( export default mixins(
externalHooks, externalHooks,
@ -230,7 +265,12 @@ export default mixins(
components: { components: {
CodeEdit, CodeEdit,
ExpressionEdit, ExpressionEdit,
NodeCredentials,
CredentialsSelect,
PrismEditor, PrismEditor,
ScopesNotice,
ParameterOptions,
ParameterIssues,
TextEdit, TextEdit,
}, },
props: [ props: [
@ -257,6 +297,8 @@ export default mixins(
remoteParameterOptionsLoadingIssues: null as string | null, remoteParameterOptionsLoadingIssues: null as string | null,
textEditDialogVisible: false, textEditDialogVisible: false,
tempValue: '', // el-date-picker and el-input does not seem to work without v-model so add one tempValue: '', // el-date-picker and el-input does not seem to work without v-model so add one
CUSTOM_API_CALL_KEY,
activeCredentialType: '',
dateTimePickerOptions: { dateTimePickerOptions: {
shortcuts: [ shortcuts: [
{ {
@ -303,6 +345,7 @@ export default mixins(
}, },
}, },
computed: { computed: {
...mapGetters('credentials', ['allCredentialTypes']),
areExpressionsDisabled(): boolean { areExpressionsDisabled(): boolean {
return this.$store.getters['ui/areExpressionsDisabled']; return this.$store.getters['ui/areExpressionsDisabled'];
}, },
@ -373,6 +416,13 @@ export default mixins(
returnValue = this.expressionValueComputed; returnValue = this.expressionValueComputed;
} }
if (this.parameter.type === 'credentialsSelect') {
const credType = this.$store.getters['credentials/getCredentialTypeByName'](this.value);
if (credType) {
returnValue = credType.displayName;
}
}
if (this.parameter.type === 'color' && this.getArgument('showAlpha') === true && returnValue.charAt(0) === '#') { if (this.parameter.type === 'color' && this.getArgument('showAlpha') === true && returnValue.charAt(0) === '#') {
// Convert the value to rgba that el-color-picker can display it correctly // Convert the value to rgba that el-color-picker can display it correctly
const bigint = parseInt(returnValue.slice(1), 16); const bigint = parseInt(returnValue.slice(1), 16);
@ -471,7 +521,17 @@ export default mixins(
const issues = NodeHelpers.getParameterIssues(this.parameter, this.node.parameters, newPath.join('.'), this.node); const issues = NodeHelpers.getParameterIssues(this.parameter, this.node.parameters, newPath.join('.'), this.node);
if (['options', 'multiOptions'].includes(this.parameter.type) && this.remoteParameterOptionsLoading === false && this.remoteParameterOptionsLoadingIssues === null) { if (this.parameter.type === 'credentialsSelect' && this.displayValue === '') {
issues.parameters = issues.parameters || {};
const issue = this.$locale.baseText('parameterInput.selectACredentialTypeFromTheDropdown');
issues.parameters[this.parameter.name] = [issue];
} else if (
['options', 'multiOptions'].includes(this.parameter.type) &&
this.remoteParameterOptionsLoading === false &&
this.remoteParameterOptionsLoadingIssues === null
) {
// Check if the value resolves to a valid option // Check if the value resolves to a valid option
// Currently it only displays an error in the node itself in // Currently it only displays an error in the node itself in
// case the value is not valid. The workflow can still be executed // case the value is not valid. The workflow can still be executed
@ -479,18 +539,28 @@ export default mixins(
const validOptions = this.parameterOptions!.map((options: INodePropertyOptions) => options.value); const validOptions = this.parameterOptions!.map((options: INodePropertyOptions) => options.value);
const checkValues: string[] = []; const checkValues: string[] = [];
if (Array.isArray(this.displayValue)) {
checkValues.push.apply(checkValues, this.displayValue); if (!this.skipCheck(this.displayValue)) {
} else { if (Array.isArray(this.displayValue)) {
checkValues.push(this.displayValue as string); checkValues.push.apply(checkValues, this.displayValue);
} else {
checkValues.push(this.displayValue as string);
}
} }
for (const checkValue of checkValues) { for (const checkValue of checkValues) {
if (checkValue !== undefined && checkValue.includes(CUSTOM_API_CALL_KEY)) continue;
if (checkValue === null || !validOptions.includes(checkValue)) { if (checkValue === null || !validOptions.includes(checkValue)) {
if (issues.parameters === undefined) { if (issues.parameters === undefined) {
issues.parameters = {}; issues.parameters = {};
} }
issues.parameters[this.parameter.name] = [`The value "${checkValue}" is not supported!`];
const issue = this.$locale.baseText(
'parameterInput.theValueIsNotSupported',
{ interpolate: { checkValue } },
);
issues.parameters[this.parameter.name] = [issue];
} }
} }
} else if (this.remoteParameterOptionsLoadingIssues !== null) { } else if (this.remoteParameterOptionsLoadingIssues !== null) {
@ -557,6 +627,9 @@ export default mixins(
const styles = { const styles = {
width: '100%', width: '100%',
}; };
if (this.parameter.type === 'credentialsSelect') {
return styles;
}
if (this.displayOptionsComputed === true) { if (this.displayOptionsComputed === true) {
deductWidth += 25; deductWidth += 25;
} }
@ -583,6 +656,23 @@ export default mixins(
}, },
}, },
methods: { methods: {
credentialSelected (updateInformation: INodeUpdatePropertiesInformation) {
// Update the values on the node
this.$store.commit('updateNodeProperties', updateInformation);
const node = this.$store.getters.getNodeByName(updateInformation.name);
// Update the issues
this.updateNodeCredentialIssues(node);
this.$externalHooks().run('nodeSettings.credentialSelected', { updateInformation });
},
/**
* Check whether a param value must be skipped when collecting node param issues for validation.
*/
skipCheck(value: string | number | boolean | null) {
return typeof value === 'string' && value.includes(CUSTOM_API_CALL_KEY);
},
getPlaceholder(): string { getPlaceholder(): string {
return this.isForCredential return this.isForCredential
? this.$locale.credText().placeholder(this.parameter) ? this.$locale.credText().placeholder(this.parameter)
@ -737,6 +827,10 @@ export default mixins(
this.$emit('textInput', parameterData); this.$emit('textInput', parameterData);
}, },
valueChanged (value: string[] | string | number | boolean | Date | null) { valueChanged (value: string[] | string | number | boolean | Date | null) {
if (this.parameter.name === 'nodeCredentialType') {
this.activeCredentialType = value as string;
}
if (value instanceof Date) { if (value instanceof Date) {
value = value.toISOString(); value = value.toISOString();
} }
@ -790,6 +884,10 @@ export default mixins(
this.nodeName = this.node.name; this.nodeName = this.node.name;
} }
if (this.node && this.node.parameters.authentication === 'predefinedCredentialType') {
this.activeCredentialType = this.node.parameters.nodeCredentialType as string;
}
if (this.parameter.type === 'color' && this.getArgument('showAlpha') === true && this.displayValue !== null && this.displayValue.toString().charAt(0) !== '#') { if (this.parameter.type === 'color' && this.getArgument('showAlpha') === true && this.displayValue !== null && this.displayValue.toString().charAt(0) !== '#') {
const newValue = this.rgbaToHex(this.displayValue as string); const newValue = this.rgbaToHex(this.displayValue as string);
if (newValue !== null) { if (newValue !== null) {
@ -856,20 +954,6 @@ export default mixins(
display: inline-block; display: inline-block;
} }
.parameter-options {
width: 25px;
text-align: right;
float: right;
}
.parameter-issues {
width: 20px;
text-align: right;
float: right;
color: #ff8080;
font-size: var(--font-size-s);
}
::v-deep .color-input { ::v-deep .color-input {
display: flex; display: flex;

View file

@ -1,6 +1,8 @@
<template> <template>
<div class="paramter-input-list-wrapper"> <div class="parameter-input-list-wrapper">
<div v-for="parameter in filteredParameters" :key="parameter.name" :class="{indent}"> <div v-for="(parameter, index) in filteredParameters" :key="parameter.name">
<slot v-if="indexToShowSlotAt === index" />
<div <div
v-if="multipleValues(parameter) === true && parameter.type !== 'fixedCollection'" v-if="multipleValues(parameter) === true && parameter.type !== 'fixedCollection'"
class="parameter-item" class="parameter-item"
@ -18,8 +20,6 @@
v-else-if="parameter.type === 'notice'" v-else-if="parameter.type === 'notice'"
class="parameter-item" class="parameter-item"
:content="$locale.nodeText().inputLabelDisplayName(parameter, path)" :content="$locale.nodeText().inputLabelDisplayName(parameter, path)"
:truncate="parameter.typeOptions && parameter.typeOptions.truncate"
:truncate-at="parameter.typeOptions && parameter.typeOptions.truncateAt"
/> />
<div <div
@ -101,6 +101,7 @@ import ParameterInputFull from '@/components/ParameterInputFull.vue';
import { get, set } from 'lodash'; import { get, set } from 'lodash';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { WEBHOOK_NODE_TYPE } from '@/constants';
export default mixins( export default mixins(
genericHelpers, genericHelpers,
@ -129,6 +130,11 @@ export default mixins(
node (): INodeUi { node (): INodeUi {
return this.$store.getters.activeNode; return this.$store.getters.activeNode;
}, },
indexToShowSlotAt (): number {
if (this.node.type === WEBHOOK_NODE_TYPE) return 1;
return 0;
},
}, },
methods: { methods: {
multipleValues (parameter: INodeProperties): boolean { multipleValues (parameter: INodeProperties): boolean {
@ -164,11 +170,27 @@ export default mixins(
this.$emit('valueChanged', parameterData); this.$emit('valueChanged', parameterData);
}, },
mustHideDuringCustomApiCall (parameter: INodeProperties, nodeValues: INodeParameters): boolean {
if (parameter && parameter.displayOptions && parameter.displayOptions.hide) return true;
const MUST_REMAIN_VISIBLE = ['authentication', 'resource', 'operation', ...Object.keys(nodeValues)];
return !MUST_REMAIN_VISIBLE.includes(parameter.name);
},
displayNodeParameter (parameter: INodeProperties): boolean { displayNodeParameter (parameter: INodeProperties): boolean {
if (parameter.type === 'hidden') { if (parameter.type === 'hidden') {
return false; return false;
} }
if (
this.isCustomApiCallSelected(this.nodeValues) &&
this.mustHideDuringCustomApiCall(parameter, this.nodeValues)
) {
return false;
}
if (parameter.displayOptions === undefined) { if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check // If it is not defined no need to do a proper check
return true; return true;
@ -260,7 +282,7 @@ export default mixins(
</script> </script>
<style lang="scss"> <style lang="scss">
.paramter-input-list-wrapper { .parameter-input-list-wrapper {
.delete-option { .delete-option {
display: none; display: none;
position: absolute; position: absolute;

View file

@ -0,0 +1,30 @@
<template>
<div :class="$style['parameter-issues']" v-if="issues.length">
<n8n-tooltip placement="top" >
<div slot="content" v-html="`${$locale.baseText('parameterInput.issues')}:<br />&nbsp;&nbsp;- ` + issues.join('<br />&nbsp;&nbsp;- ')"></div>
<font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'ParameterIssues',
props: [
'issues',
],
});
</script>
<style module lang="scss">
.parameter-issues {
width: 20px;
text-align: right;
float: right;
color: #ff8080;
font-size: var(--font-size-s);
padding-left: var(--spacing-4xs);
}
</style>

View file

@ -0,0 +1,68 @@
<template>
<div :class="$style['parameter-options']">
<el-dropdown
trigger="click"
@command="(opt) => $emit('optionSelected', opt)"
size="mini"
>
<span class="el-dropdown-link">
<font-awesome-icon
icon="cogs"
class="reset-icon clickable"
:title="$locale.baseText('parameterInput.parameterOptions')"
/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-if="parameter.noDataExpression !== true && !isValueExpression"
command="addExpression"
>
{{ $locale.baseText('parameterInput.addExpression') }}
</el-dropdown-item>
<el-dropdown-item
v-if="parameter.noDataExpression !== true && isValueExpression"
command="removeExpression"
>
{{ $locale.baseText('parameterInput.removeExpression') }}
</el-dropdown-item>
<el-dropdown-item
v-if="hasRemoteMethod"
command="refreshOptions"
>
{{ $locale.baseText('parameterInput.refreshList') }}
</el-dropdown-item>
<el-dropdown-item
command="resetValue"
:disabled="isDefault"
divided
>
{{ $locale.baseText('parameterInput.resetValue') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'ParameterOptions',
props: [
'displayOptionsComputed',
'optionSelected',
'parameter',
'isValueExpression',
'isDefault',
'hasRemoteMethod',
],
});
</script>
<style module lang="scss">
.parameter-options {
width: 25px;
text-align: right;
float: right;
}
</style>

View file

@ -0,0 +1,55 @@
<template>
<n8n-notice
:content="scopesShortContent"
:fullContent="scopesFullContent"
/>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapGetters } from 'vuex';
export default Vue.extend({
name: 'ScopesNotice',
props: [
'activeCredentialType',
'scopes',
],
computed: {
...mapGetters('credentials', ['getCredentialTypeByName']),
scopesShortContent (): string {
return this.$locale.baseText(
'nodeSettings.scopes.notice',
{
adjustToNumber: this.scopes.length,
interpolate: {
activeCredential: this.shortCredentialDisplayName,
},
},
);
},
scopesFullContent (): string {
return this.$locale.baseText(
'nodeSettings.scopes.expandedNoticeWithScopes',
{
adjustToNumber: this.scopes.length,
interpolate: {
activeCredential: this.shortCredentialDisplayName,
scopes: this.scopes.map(
(s: string) => s.includes('/') ? s.split('/').pop() : s,
).join('<br>'),
},
},
);
},
shortCredentialDisplayName (): string {
const oauth1Api = this.$locale.baseText('generic.oauth1Api');
const oauth2Api = this.$locale.baseText('generic.oauth2Api');
return this.getCredentialTypeByName(this.activeCredentialType).displayName
.replace(new RegExp(`${oauth1Api}|${oauth2Api}`), '')
.trim();
},
},
});
</script>

View file

@ -1,5 +1,6 @@
import { import {
PLACEHOLDER_FILLED_AT_EXECUTION_TIME, PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
CUSTOM_API_CALL_KEY,
} from '@/constants'; } from '@/constants';
import { import {
@ -32,12 +33,30 @@ import { restApi } from '@/components/mixins/restApi';
import { get } from 'lodash'; import { get } from 'lodash';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
export const nodeHelpers = mixins( export const nodeHelpers = mixins(
restApi, restApi,
) )
.extend({ .extend({
computed: {
...mapGetters('credentials', [ 'getCredentialTypeByName', 'getCredentialsByType' ]),
},
methods: { methods: {
hasProxyAuth (node: INodeUi): boolean {
return Object.keys(node.parameters).includes('nodeCredentialType');
},
isCustomApiCallSelected (nodeValues: INodeParameters): boolean {
const { parameters } = nodeValues;
if (!isObjectLiteral(parameters)) return false;
return (
parameters.resource !== undefined && parameters.resource.includes(CUSTOM_API_CALL_KEY) ||
parameters.operation !== undefined && parameters.operation.includes(CUSTOM_API_CALL_KEY)
);
},
// Returns the parameter value // Returns the parameter value
getParameterValue (nodeValues: INodeParameters, parameterName: string, path: string) { getParameterValue (nodeValues: INodeParameters, parameterName: string, path: string) {
@ -116,6 +135,23 @@ export const nodeHelpers = mixins(
return false; return false;
}, },
reportUnsetCredential(credentialType: ICredentialType) {
return {
credentials: {
[credentialType.name]: [
this.$locale.baseText(
'nodeHelpers.credentialsUnset',
{
interpolate: {
credentialType: credentialType.displayName,
},
},
),
],
},
};
},
// Updates the execution issues. // Updates the execution issues.
updateNodesExecutionIssues () { updateNodesExecutionIssues () {
const nodes = this.$store.getters.allNodes; const nodes = this.$store.getters.allNodes;
@ -198,6 +234,46 @@ export const nodeHelpers = mixins(
let credentialType: ICredentialType | null; let credentialType: ICredentialType | null;
let credentialDisplayName: string; let credentialDisplayName: string;
let selectedCredentials: INodeCredentialsDetails; let selectedCredentials: INodeCredentialsDetails;
const {
authentication,
genericAuthType,
nodeCredentialType,
} = node.parameters as HttpRequestNode.V2.AuthParams;
if (
authentication === 'genericCredentialType' &&
genericAuthType !== '' &&
selectedCredsAreUnusable(node, genericAuthType)
) {
const credential = this.getCredentialTypeByName(genericAuthType);
return this.reportUnsetCredential(credential);
}
if (
this.hasProxyAuth(node) &&
authentication === 'predefinedCredentialType' &&
nodeCredentialType !== '' &&
node.credentials !== undefined
) {
const stored = this.getCredentialsByType(nodeCredentialType);
if (selectedCredsDoNotExist(node, nodeCredentialType, stored)) {
const credential = this.getCredentialTypeByName(nodeCredentialType);
return this.reportUnsetCredential(credential);
}
}
if (
this.hasProxyAuth(node) &&
authentication === 'predefinedCredentialType' &&
nodeCredentialType !== '' &&
selectedCredsAreUnusable(node, nodeCredentialType)
) {
const credential = this.getCredentialTypeByName(nodeCredentialType);
return this.reportUnsetCredential(credential);
}
for (const credentialTypeDescription of nodeType!.credentials!) { for (const credentialTypeDescription of nodeType!.credentials!) {
// Check if credentials should be displayed else ignore // Check if credentials should be displayed else ignore
if (this.displayParameter(node.parameters, credentialTypeDescription, '', node) !== true) { if (this.displayParameter(node.parameters, credentialTypeDescription, '', node) !== true) {
@ -398,3 +474,43 @@ export const nodeHelpers = mixins(
}, },
}, },
}); });
/**
* Whether the node has no selected credentials, or none of the node's
* selected credentials are of the specified type.
*/
function selectedCredsAreUnusable(node: INodeUi, credentialType: string) {
return node.credentials === undefined || Object.keys(node.credentials).includes(credentialType) === false;
}
/**
* Whether the node's selected credentials of the specified type
* can no longer be found in the database.
*/
function selectedCredsDoNotExist(
node: INodeUi,
nodeCredentialType: string,
storedCredsByType: ICredentialsResponse[] | null,
) {
if (!node.credentials || !storedCredsByType) return false;
const selectedCredsByType = node.credentials[nodeCredentialType];
if (!selectedCredsByType) return false;
return !storedCredsByType.find((c) => c.id === selectedCredsByType.id);
}
declare namespace HttpRequestNode {
namespace V2 {
type AuthParams = {
authentication: 'none' | 'genericCredentialType' | 'predefinedCredentialType';
genericAuthType: string;
nodeCredentialType: string;
};
}
}
function isObjectLiteral(maybeObject: unknown): maybeObject is { [key: string]: string } {
return typeof maybeObject === 'object' && maybeObject !== null && !Array.isArray(maybeObject);
}

View file

@ -27,8 +27,13 @@ export const showMessage = mixins(externalHooks).extend({
stickyNotificationQueue.push(notification); stickyNotificationQueue.push(notification);
} }
if(messageData.type === 'error' && track) { if (messageData.type === 'error' && track) {
this.$telemetry.track('Instance FE emitted error', { error_title: messageData.title, error_message: messageData.message, workflow_id: this.$store.getters.workflowId }); this.$telemetry.track('Instance FE emitted error', {
error_title: messageData.title,
error_message: messageData.message,
caused_by_credential: this.causedByCredential(messageData.message),
workflow_id: this.$store.getters.workflowId,
});
} }
return notification; return notification;
@ -135,7 +140,14 @@ export const showMessage = mixins(externalHooks).extend({
message, message,
errorMessage: error.message, errorMessage: error.message,
}); });
this.$telemetry.track('Instance FE emitted error', { error_title: title, error_description: message, error_message: error.message, workflow_id: this.$store.getters.workflowId });
this.$telemetry.track('Instance FE emitted error', {
error_title: title,
error_description: message,
error_message: error.message,
caused_by_credential: this.causedByCredential(error.message),
workflow_id: this.$store.getters.workflowId,
});
}, },
async confirmMessage (message: string, headline: string, type: MessageType | null = 'warning', confirmButtonText?: string, cancelButtonText?: string): Promise<boolean> { async confirmMessage (message: string, headline: string, type: MessageType | null = 'warning', confirmButtonText?: string, cancelButtonText?: string): Promise<boolean> {
@ -203,5 +215,14 @@ export const showMessage = mixins(externalHooks).extend({
</details> </details>
`; `;
}, },
/**
* Whether a workflow execution error was caused by a credential issue, as reflected by the error message.
*/
causedByCredential(message: string | undefined) {
if (!message) return false;
return message.includes('Credentials for') && message.includes('are not set');
},
}, },
}); });

View file

@ -330,6 +330,11 @@ export const workflowHelpers = mixins(
if (node.credentials !== undefined && nodeType.credentials !== undefined) { if (node.credentials !== undefined && nodeType.credentials !== undefined) {
const saveCredenetials: INodeCredentials = {}; const saveCredenetials: INodeCredentials = {};
for (const nodeCredentialTypeName of Object.keys(node.credentials)) { for (const nodeCredentialTypeName of Object.keys(node.credentials)) {
if (this.hasProxyAuth(node) || Object.keys(node.parameters).includes('genericAuthType')) {
saveCredenetials[nodeCredentialTypeName] = node.credentials[nodeCredentialTypeName];
continue;
}
const credentialTypeDescription = nodeType.credentials const credentialTypeDescription = nodeType.credentials
.find((credentialTypeDescription) => credentialTypeDescription.name === nodeCredentialTypeName); .find((credentialTypeDescription) => credentialTypeDescription.name === nodeCredentialTypeName);

View file

@ -4,6 +4,9 @@ export const NODE_NAME_PREFIX = 'node-';
export const PLACEHOLDER_FILLED_AT_EXECUTION_TIME = '[filled at execution time]'; export const PLACEHOLDER_FILLED_AT_EXECUTION_TIME = '[filled at execution time]';
// parameter input
export const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
// workflows // workflows
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__'; export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
export const DEFAULT_NODETYPE_VERSION = 1; export const DEFAULT_NODETYPE_VERSION = 1;

View file

@ -23,6 +23,7 @@ import {
ICredentialsDecrypted, ICredentialsDecrypted,
INodeCredentialTestResult, INodeCredentialTestResult,
INodeTypeDescription, INodeTypeDescription,
INodeProperties,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { getAppNameFromCredType } from '@/components/helpers'; import { getAppNameFromCredType } from '@/components/helpers';
@ -120,6 +121,35 @@ const module: Module<ICredentialsState, IRootState> = {
}); });
}; };
}, },
getScopesByCredentialType (_: ICredentialsState, getters: any) { // tslint:disable-line:no-any
return (credentialTypeName: string) => {
const credentialType = getters.getCredentialTypeByName(credentialTypeName) as {
properties: INodeProperties[];
};
const scopeProperty = credentialType.properties.find((p) => p.name === 'scope');
if (
!scopeProperty ||
!scopeProperty.default ||
typeof scopeProperty.default !== 'string' ||
scopeProperty.default === ''
) {
return [];
}
let { default: scopeDefault } = scopeProperty;
// disregard expressions for display
scopeDefault = scopeDefault.replace(/^=/, '').replace(/\{\{.*\}\}/, '');
if (/ /.test(scopeDefault)) return scopeDefault.split(' ');
if (/,/.test(scopeDefault)) return scopeDefault.split(',');
return [scopeDefault];
};
},
}, },
actions: { actions: {
fetchCredentialTypes: async (context: ActionContext<ICredentialsState, IRootState>) => { fetchCredentialTypes: async (context: ActionContext<ICredentialsState, IRootState>) => {

View file

@ -318,8 +318,8 @@ Allowed subkeys: `options.{optionName}.displayName` and `options.{optionName}.de
{ {
"nodeView.resource.displayName": "🇩🇪 Resource", "nodeView.resource.displayName": "🇩🇪 Resource",
"nodeView.resource.description": "🇩🇪 Resource to operate on", "nodeView.resource.description": "🇩🇪 Resource to operate on",
"nodeView.resource.options.file.displayName": "🇩🇪 File", "nodeView.resource.options.file.name": "🇩🇪 File",
"nodeView.resource.options.issue.displayName": "🇩🇪 Issue", "nodeView.resource.options.issue.name": "🇩🇪 Issue",
} }
``` ```
@ -327,6 +327,16 @@ Allowed subkeys: `options.{optionName}.displayName` and `options.{optionName}.de
<img src="img/node2.png" width="400"> <img src="img/node2.png" width="400">
</p> </p>
For nodes whose credentials may be used in the HTTP Request node, an additional option `Custom API Call` is injected into the `Resource` and `Operation` parameters. Use the `__CUSTOM_API_CALL__` key to translate this additional option.
```json
{
"nodeView.resource.options.file.name": "🇩🇪 File",
"nodeView.resource.options.issue.name": "🇩🇪 Issue",
"nodeView.resource.options.__CUSTOM_API_CALL__.name": "🇩🇪 Custom API Call",
}
```
#### `collection` and `fixedCollection` parameters #### `collection` and `fixedCollection` parameters
Allowed keys: `displayName`, `description`, `placeholder`, `multipleValueButtonText` Allowed keys: `displayName`, `description`, `placeholder`, `multipleValueButtonText`

View file

@ -242,6 +242,8 @@
"forgotPassword.returnToSignIn": "Back to sign in", "forgotPassword.returnToSignIn": "Back to sign in",
"forgotPassword.sendingEmailError": "Problem sending email", "forgotPassword.sendingEmailError": "Problem sending email",
"forgotPassword.smtpErrorContactAdministrator": "Please contact your administrator (problem with your SMTP setup)", "forgotPassword.smtpErrorContactAdministrator": "Please contact your administrator (problem with your SMTP setup)",
"generic.oauth1Api": "OAuth1 API",
"generic.oauth2Api": "OAuth2 API",
"genericHelpers.loading": "Loading", "genericHelpers.loading": "Loading",
"genericHelpers.min": "min", "genericHelpers.min": "min",
"genericHelpers.sec": "sec", "genericHelpers.sec": "sec",
@ -402,6 +404,7 @@
"nodeErrorView.stack": "Stack", "nodeErrorView.stack": "Stack",
"nodeErrorView.theErrorCauseIsTooLargeToBeDisplayed": "The error cause is too large to be displayed", "nodeErrorView.theErrorCauseIsTooLargeToBeDisplayed": "The error cause is too large to be displayed",
"nodeErrorView.time": "Time", "nodeErrorView.time": "Time",
"nodeHelpers.credentialsUnset": "Credentials for '{credentialType}' are not set.",
"nodeSettings.alwaysOutputData.description": "If active, will output a single, empty item when the output would have been empty. Use to prevent the workflow finishing on this node.", "nodeSettings.alwaysOutputData.description": "If active, will output a single, empty item when the output would have been empty. Use to prevent the workflow finishing on this node.",
"nodeSettings.alwaysOutputData.displayName": "Always Output Data", "nodeSettings.alwaysOutputData.displayName": "Always Output Data",
"nodeSettings.clickOnTheQuestionMarkIcon": "Click the '?' icon to open this node on n8n.io", "nodeSettings.clickOnTheQuestionMarkIcon": "Click the '?' icon to open this node on n8n.io",
@ -421,8 +424,11 @@
"nodeSettings.parameters": "Parameters", "nodeSettings.parameters": "Parameters",
"nodeSettings.retryOnFail.description": "If active, the node tries to execute again when it fails", "nodeSettings.retryOnFail.description": "If active, the node tries to execute again when it fails",
"nodeSettings.retryOnFail.displayName": "Retry On Fail", "nodeSettings.retryOnFail.displayName": "Retry On Fail",
"nodeSettings.scopes.expandedNoticeWithScopes": "<a data-key=\"toggle-expand\">{count} scope</a> available for {activeCredential} credentials<br>{scopes}<br><a data-key=\"show-less\">Show less</a> | <a data-key=\"toggle-expand\">{count} scopes</a> available for {activeCredential} credentials<br>{scopes}<br><a data-key=\"show-less\">Show less</a>",
"nodeSettings.scopes.notice": "<a data-key=\"toggle-expand\">{count} scope</a> available for {activeCredential} credentials | <a data-key=\"toggle-expand\">{count} scopes</a> available for {activeCredential} credentials",
"nodeSettings.theNodeIsNotValidAsItsTypeIsUnknown": "The node is not valid as its type ({nodeType}) is unknown", "nodeSettings.theNodeIsNotValidAsItsTypeIsUnknown": "The node is not valid as its type ({nodeType}) is unknown",
"nodeSettings.thisNodeDoesNotHaveAnyParameters": "This node does not have any parameters", "nodeSettings.thisNodeDoesNotHaveAnyParameters": "This node does not have any parameters",
"nodeSettings.useTheHttpRequestNode": "Use the <b>HTTP Request</b> node to make a custom API call. We'll take care of the {nodeTypeDisplayName} auth for you. <a target=\"_blank\" href=\"https://docs.n8n.io/integrations/custom-operations/\">Learn more</a>",
"nodeSettings.waitBetweenTries.description": "How long to wait between each attempt (in milliseconds)", "nodeSettings.waitBetweenTries.description": "How long to wait between each attempt (in milliseconds)",
"nodeSettings.waitBetweenTries.displayName": "Wait Between Tries (ms)", "nodeSettings.waitBetweenTries.displayName": "Wait Between Tries (ms)",
"nodeView.addNode": "Add node", "nodeView.addNode": "Add node",
@ -499,6 +505,7 @@
"openWorkflow.workflowImportError": "Could not import workflow", "openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowNotFoundError": "Could not find workflow", "openWorkflow.workflowNotFoundError": "Could not find workflow",
"parameterInput.addExpression": "Add Expression", "parameterInput.addExpression": "Add Expression",
"parameterInput.customApiCall": "Custom API Call",
"parameterInput.error": "ERROR", "parameterInput.error": "ERROR",
"parameterInput.issues": "Issues", "parameterInput.issues": "Issues",
"parameterInput.loadingOptions": "Loading options...", "parameterInput.loadingOptions": "Loading options...",
@ -513,6 +520,8 @@
"parameterInput.resetValue": "Reset Value", "parameterInput.resetValue": "Reset Value",
"parameterInput.select": "Select", "parameterInput.select": "Select",
"parameterInput.selectDateAndTime": "Select date and time", "parameterInput.selectDateAndTime": "Select date and time",
"parameterInput.selectACredentialTypeFromTheDropdown": "Select a credential type from the dropdown",
"parameterInput.theValueIsNotSupported": "The value \"{checkValue}\" is not supported!",
"parameterInputExpanded.openDocs": "Open docs", "parameterInputExpanded.openDocs": "Open docs",
"parameterInputExpanded.thisFieldIsRequired": "This field is required", "parameterInputExpanded.thisFieldIsRequired": "This field is required",
"parameterInputList.delete": "Delete", "parameterInputList.delete": "Delete",

View file

@ -5,7 +5,7 @@ import {
export class GithubApi implements ICredentialType { export class GithubApi implements ICredentialType {
name = 'githubApi'; name = 'githubApi';
displayName = 'Github API'; displayName = 'GitHub API';
documentationUrl = 'github'; documentationUrl = 'github';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {

View file

@ -9,7 +9,7 @@ export class GithubOAuth2Api implements ICredentialType {
extends = [ extends = [
'oAuth2Api', 'oAuth2Api',
]; ];
displayName = 'Github OAuth2 API'; displayName = 'GitHub OAuth2 API';
documentationUrl = 'github'; documentationUrl = 'github';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {

View file

@ -6,7 +6,7 @@ import {
export class GitlabApi implements ICredentialType { export class GitlabApi implements ICredentialType {
name = 'gitlabApi'; name = 'gitlabApi';
displayName = 'Gitlab API'; displayName = 'GitLab API';
documentationUrl = 'gitlab'; documentationUrl = 'gitlab';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {

View file

@ -9,7 +9,7 @@ export class GitlabOAuth2Api implements ICredentialType {
extends = [ extends = [
'oAuth2Api', 'oAuth2Api',
]; ];
displayName = 'Gitlab OAuth2 API'; displayName = 'GitLab OAuth2 API';
documentationUrl = 'gitlab'; documentationUrl = 'gitlab';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {

View file

@ -8,6 +8,7 @@ export class HttpBasicAuth implements ICredentialType {
name = 'httpBasicAuth'; name = 'httpBasicAuth';
displayName = 'Basic Auth'; displayName = 'Basic Auth';
documentationUrl = 'httpRequest'; documentationUrl = 'httpRequest';
genericAuth = true;
icon = 'node:n8n-nodes-base.httpRequest'; icon = 'node:n8n-nodes-base.httpRequest';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {

View file

@ -8,6 +8,7 @@ export class HttpDigestAuth implements ICredentialType {
name = 'httpDigestAuth'; name = 'httpDigestAuth';
displayName = 'Digest Auth'; displayName = 'Digest Auth';
documentationUrl = 'httpRequest'; documentationUrl = 'httpRequest';
genericAuth = true;
icon = 'node:n8n-nodes-base.httpRequest'; icon = 'node:n8n-nodes-base.httpRequest';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {

View file

@ -9,6 +9,7 @@ export class HttpHeaderAuth implements ICredentialType {
name = 'httpHeaderAuth'; name = 'httpHeaderAuth';
displayName = 'Header Auth'; displayName = 'Header Auth';
documentationUrl = 'httpRequest'; documentationUrl = 'httpRequest';
genericAuth = true;
icon = 'node:n8n-nodes-base.httpRequest'; icon = 'node:n8n-nodes-base.httpRequest';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {

View file

@ -8,6 +8,7 @@ export class HttpQueryAuth implements ICredentialType {
name = 'httpQueryAuth'; name = 'httpQueryAuth';
displayName = 'Query Auth'; displayName = 'Query Auth';
documentationUrl = 'httpRequest'; documentationUrl = 'httpRequest';
genericAuth = true;
icon = 'node:n8n-nodes-base.httpRequest'; icon = 'node:n8n-nodes-base.httpRequest';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {

View file

@ -5,7 +5,7 @@ import {
export class HubspotApi implements ICredentialType { export class HubspotApi implements ICredentialType {
name = 'hubspotApi'; name = 'hubspotApi';
displayName = 'Hubspot API'; displayName = 'HubSpot API';
documentationUrl = 'hubspot'; documentationUrl = 'hubspot';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {

View file

@ -5,7 +5,7 @@ import {
export class HubspotAppToken implements ICredentialType { export class HubspotAppToken implements ICredentialType {
name = 'hubspotAppToken'; name = 'hubspotAppToken';
displayName = 'Hubspot App Token'; displayName = 'HubSpot App Token';
documentationUrl = 'hubspot'; documentationUrl = 'hubspot';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {

View file

@ -14,7 +14,7 @@ const scopes = [
export class HubspotDeveloperApi implements ICredentialType { export class HubspotDeveloperApi implements ICredentialType {
name = 'hubspotDeveloperApi'; name = 'hubspotDeveloperApi';
displayName = 'Hubspot Developer API'; displayName = 'HubSpot Developer API';
documentationUrl = 'hubspot'; documentationUrl = 'hubspot';
extends = [ extends = [
'oAuth2Api', 'oAuth2Api',

View file

@ -23,7 +23,7 @@ export class HubspotOAuth2Api implements ICredentialType {
extends = [ extends = [
'oAuth2Api', 'oAuth2Api',
]; ];
displayName = 'Hubspot OAuth2 API'; displayName = 'HubSpot OAuth2 API';
documentationUrl = 'hubspot'; documentationUrl = 'hubspot';
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {

View file

@ -7,6 +7,7 @@ export class OAuth1Api implements ICredentialType {
name = 'oAuth1Api'; name = 'oAuth1Api';
displayName = 'OAuth1 API'; displayName = 'OAuth1 API';
documentationUrl = 'httpRequest'; documentationUrl = 'httpRequest';
genericAuth = true;
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {
displayName: 'Authorization URL', displayName: 'Authorization URL',

View file

@ -8,6 +8,7 @@ export class OAuth2Api implements ICredentialType {
name = 'oAuth2Api'; name = 'oAuth2Api';
displayName = 'OAuth2 API'; displayName = 'OAuth2 API';
documentationUrl = 'httpRequest'; documentationUrl = 'httpRequest';
genericAuth = true;
properties: INodeProperties[] = [ properties: INodeProperties[] = [
{ {
displayName: 'Authorization URL', displayName: 'Authorization URL',

View file

@ -1,10 +1,14 @@
import path from 'path';
import { import {
IExecuteFunctions, IExecuteFunctions,
} from 'n8n-core'; } from 'n8n-core';
import { import {
IAuthenticate,
IBinaryData, IBinaryData,
IDataObject, IDataObject,
ILoadOptionsFunctions,
INodeExecutionData, INodeExecutionData,
INodePropertyOptions,
INodeType, INodeType,
INodeTypeDescription, INodeTypeDescription,
NodeApiError, NodeApiError,
@ -29,7 +33,7 @@ export class HttpRequest implements INodeType {
name: 'httpRequest', name: 'httpRequest',
icon: 'fa:at', icon: 'fa:at',
group: ['input'], group: ['input'],
version: 1, version: [1, 2],
subtitle: '={{$parameter["requestMethod"] + ": " + $parameter["url"]}}', subtitle: '={{$parameter["requestMethod"] + ": " + $parameter["url"]}}',
description: 'Makes an HTTP request and returns the response data', description: 'Makes an HTTP request and returns the response data',
defaults: { defaults: {
@ -39,6 +43,97 @@ export class HttpRequest implements INodeType {
inputs: ['main'], inputs: ['main'],
outputs: ['main'], outputs: ['main'],
credentials: [ credentials: [
// ----------------------------------
// v2 creds
// ----------------------------------
{
name: 'httpBasicAuth',
required: true,
displayOptions: {
show: {
authentication: [
'httpBasicAuth',
],
'@version': [
2,
],
},
},
},
{
name: 'httpDigestAuth',
required: true,
displayOptions: {
show: {
authentication: [
'httpDigestAuth',
],
'@version': [
2,
],
},
},
},
{
name: 'httpHeaderAuth',
required: true,
displayOptions: {
show: {
authentication: [
'httpHeaderAuth',
],
'@version': [
2,
],
},
},
},
{
name: 'httpQueryAuth',
required: true,
displayOptions: {
show: {
authentication: [
'httpQueryAuth',
],
'@version': [
2,
],
},
},
},
{
name: 'oAuth1Api',
required: true,
displayOptions: {
show: {
authentication: [
'oAuth1Api',
],
'@version': [
2,
],
},
},
},
{
name: 'oAuth2Api',
required: true,
displayOptions: {
show: {
authentication: [
'oAuth2Api',
],
'@version': [
2,
],
},
},
},
// ----------------------------------
// v1 creds
// ----------------------------------
{ {
name: 'httpBasicAuth', name: 'httpBasicAuth',
required: true, required: true,
@ -47,6 +142,9 @@ export class HttpRequest implements INodeType {
authentication: [ authentication: [
'basicAuth', 'basicAuth',
], ],
'@version': [
1,
],
}, },
}, },
}, },
@ -58,6 +156,9 @@ export class HttpRequest implements INodeType {
authentication: [ authentication: [
'digestAuth', 'digestAuth',
], ],
'@version': [
1,
],
}, },
}, },
}, },
@ -69,6 +170,9 @@ export class HttpRequest implements INodeType {
authentication: [ authentication: [
'headerAuth', 'headerAuth',
], ],
'@version': [
1,
],
}, },
}, },
}, },
@ -80,6 +184,9 @@ export class HttpRequest implements INodeType {
authentication: [ authentication: [
'queryAuth', 'queryAuth',
], ],
'@version': [
1,
],
}, },
}, },
}, },
@ -91,6 +198,9 @@ export class HttpRequest implements INodeType {
authentication: [ authentication: [
'oAuth1', 'oAuth1',
], ],
'@version': [
1,
],
}, },
}, },
}, },
@ -107,6 +217,87 @@ export class HttpRequest implements INodeType {
}, },
], ],
properties: [ properties: [
// ----------------------------------
// v2 params
// ----------------------------------
{
displayName: 'Authentication',
name: 'authentication',
noDataExpression: true,
type: 'options',
required: true,
options: [
{
name: 'None',
value: 'none',
},
{
name: 'Predefined Credential Type',
value: 'predefinedCredentialType',
description: 'We\'ve already implemented auth for many services so that you don\'t have to set it up manually',
},
{
name: 'Generic Credential Type',
value: 'genericCredentialType',
description: 'Fully customizable. Choose between basic, header, OAuth2, etc.',
},
],
default: 'none',
displayOptions: {
show: {
'@version': [
2,
],
},
},
},
{
displayName: 'Credential Type',
name: 'nodeCredentialType',
type: 'credentialsSelect',
noDataExpression: true,
required: true,
default: '',
credentialTypes: [
'extends:oAuth2Api',
'extends:oAuth1Api',
'has:authenticate',
],
displayOptions: {
show: {
authentication: [
'predefinedCredentialType',
],
'@version': [
2,
],
},
},
},
{
displayName: 'Generic Auth Type',
name: 'genericAuthType',
type: 'credentialsSelect',
required: true,
default: '',
credentialTypes: [
'has:genericAuth',
],
displayOptions: {
show: {
authentication: [
'genericCredentialType',
],
'@version': [
2,
],
},
},
},
// ----------------------------------
// v1 params
// ----------------------------------
{ {
displayName: 'Authentication', displayName: 'Authentication',
name: 'authentication', name: 'authentication',
@ -143,7 +334,18 @@ export class HttpRequest implements INodeType {
], ],
default: 'none', default: 'none',
description: 'The way to authenticate', description: 'The way to authenticate',
displayOptions: {
show: {
'@version': [
1,
],
},
},
}, },
// ----------------------------------
// versionless params
// ----------------------------------
{ {
displayName: 'Request Method', displayName: 'Request Method',
name: 'requestMethod', name: 'requestMethod',
@ -642,7 +844,6 @@ export class HttpRequest implements INodeType {
], ],
}; };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData(); const items = this.getInputData();
@ -653,47 +854,46 @@ export class HttpRequest implements INodeType {
'statusMessage', 'statusMessage',
]; ];
// TODO: Should have a setting which makes clear that this parameter can not change for each item let authentication;
const requestMethod = this.getNodeParameter('requestMethod', 0) as string; const nodeVersion = this.getNode().typeVersion;
const parametersAreJson = this.getNodeParameter('jsonParameters', 0) as boolean;
const responseFormat = this.getNodeParameter('responseFormat', 0) as string; const responseFormat = this.getNodeParameter('responseFormat', 0) as string;
try {
authentication = this.getNodeParameter('authentication', 0) as 'predefinedCredentialType' | 'genericCredentialType' | 'none';
} catch (_) {}
let httpBasicAuth; let httpBasicAuth;
let httpDigestAuth; let httpDigestAuth;
let httpHeaderAuth; let httpHeaderAuth;
let httpQueryAuth; let httpQueryAuth;
let oAuth1Api; let oAuth1Api;
let oAuth2Api; let oAuth2Api;
let nodeCredentialType;
try { if (authentication === 'genericCredentialType' || nodeVersion === 1) {
httpBasicAuth = await this.getCredentials('httpBasicAuth'); try {
} catch (error) { httpBasicAuth = await this.getCredentials('httpBasicAuth');
// Do nothing } catch (_) {}
} try {
try { httpDigestAuth = await this.getCredentials('httpDigestAuth');
httpDigestAuth = await this.getCredentials('httpDigestAuth'); } catch (_) {}
} catch (error) { try {
// Do nothing httpHeaderAuth = await this.getCredentials('httpHeaderAuth');
} } catch (_) {}
try { try {
httpHeaderAuth = await this.getCredentials('httpHeaderAuth'); httpQueryAuth = await this.getCredentials('httpQueryAuth');
} catch (error) { } catch (_) {}
// Do nothing try {
} oAuth1Api = await this.getCredentials('oAuth1Api');
try { } catch (_) {}
httpQueryAuth = await this.getCredentials('httpQueryAuth'); try {
} catch (error) { oAuth2Api = await this.getCredentials('oAuth2Api');
// Do nothing } catch (_) {}
} } else if (authentication === 'predefinedCredentialType') {
try { try {
oAuth1Api = await this.getCredentials('oAuth1Api'); nodeCredentialType = this.getNodeParameter('nodeCredentialType', 0) as string;
} catch (error) { } catch (_) {}
// Do nothing
}
try {
oAuth2Api = await this.getCredentials('oAuth2Api');
} catch (error) {
// Do nothing
} }
let requestOptions: OptionsWithUri; let requestOptions: OptionsWithUri;
@ -723,6 +923,9 @@ export class HttpRequest implements INodeType {
const returnItems: INodeExecutionData[] = []; const returnItems: INodeExecutionData[] = [];
const requestPromises = []; const requestPromises = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const requestMethod = this.getNodeParameter('requestMethod', itemIndex) as string;
const parametersAreJson = this.getNodeParameter('jsonParameters', itemIndex) as boolean;
const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject; const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject;
const url = this.getNodeParameter('url', itemIndex) as string; const url = this.getNodeParameter('url', itemIndex) as string;
@ -983,13 +1186,30 @@ export class HttpRequest implements INodeType {
this.sendMessageToUI(sendRequest); this.sendMessageToUI(sendRequest);
} catch (e) { } } catch (e) { }
// Now that the options are all set make the actual http request if (
if (oAuth1Api !== undefined) { authentication === 'genericCredentialType' ||
requestPromises.push(this.helpers.requestOAuth1.call(this, 'oAuth1Api', requestOptions)); authentication === 'none' ||
} else if (oAuth2Api !== undefined) { nodeVersion === 1
requestPromises.push(this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions, { tokenType: 'Bearer' })); ) {
} else { if (oAuth1Api) {
requestPromises.push(this.helpers.request(requestOptions)); requestPromises.push(
this.helpers.requestOAuth1.call(this, 'oAuth1Api', requestOptions),
);
} else if (oAuth2Api) {
requestPromises.push(
this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions, { tokenType: 'Bearer' }),
);
} else {
// bearerAuth, queryAuth, headerAuth, digestAuth, none
requestPromises.push(
this.helpers.request(requestOptions),
);
}
} else if (authentication === 'predefinedCredentialType' && nodeCredentialType) {
// service-specific cred: OAuth1, OAuth2, plain
requestPromises.push(
this.helpers.requestWithAuthentication.call(this, nodeCredentialType, requestOptions),
);
} }
} }

View file

@ -269,7 +269,6 @@
"dist/credentials/SplunkApi.credentials.js", "dist/credentials/SplunkApi.credentials.js",
"dist/credentials/SpontitApi.credentials.js", "dist/credentials/SpontitApi.credentials.js",
"dist/credentials/SpotifyOAuth2Api.credentials.js", "dist/credentials/SpotifyOAuth2Api.credentials.js",
"dist/credentials/SpotifyOAuth2Api.credentials.js",
"dist/credentials/SshPassword.credentials.js", "dist/credentials/SshPassword.credentials.js",
"dist/credentials/SshPrivateKey.credentials.js", "dist/credentials/SshPrivateKey.credentials.js",
"dist/credentials/StackbyApi.credentials.js", "dist/credentials/StackbyApi.credentials.js",

View file

@ -276,6 +276,7 @@ export interface ICredentialType {
__overwrittenProperties?: string[]; __overwrittenProperties?: string[];
authenticate?: IAuthenticate; authenticate?: IAuthenticate;
test?: ICredentialTestRequest; test?: ICredentialTestRequest;
genericAuth?: boolean;
} }
export interface ICredentialTypes { export interface ICredentialTypes {
@ -831,7 +832,8 @@ export type NodePropertyTypes =
| 'multiOptions' | 'multiOptions'
| 'number' | 'number'
| 'options' | 'options'
| 'string'; | 'string'
| 'credentialsSelect';
export type CodeAutocompleteTypes = 'function' | 'functionItem'; export type CodeAutocompleteTypes = 'function' | 'functionItem';
@ -861,8 +863,6 @@ export interface INodePropertyTypeOptions {
rows?: number; // Supported by: string rows?: number; // Supported by: string
showAlpha?: boolean; // Supported by: color showAlpha?: boolean; // Supported by: color
sortable?: boolean; // Supported when "multipleValues" set to true sortable?: boolean; // Supported when "multipleValues" set to true
truncate?: boolean; // Supported by: notice
truncateAt?: number; // Supported by: notice
[key: string]: any; [key: string]: any;
} }
@ -890,6 +890,9 @@ export interface INodeProperties {
noDataExpression?: boolean; noDataExpression?: boolean;
required?: boolean; required?: boolean;
routing?: INodePropertyRouting; routing?: INodePropertyRouting;
credentialTypes?: Array<
'extends:oAuth2Api' | 'extends:oAuth1Api' | 'has:authenticate' | 'has:genericAuth'
>;
} }
export interface INodePropertyOptions { export interface INodePropertyOptions {
name: string; name: string;
@ -1434,9 +1437,14 @@ export interface INodeGraphItem {
type: string; type: string;
resource?: string; resource?: string;
operation?: string; operation?: string;
domain?: string; domain?: string; // HTTP Request node v1
domain_base?: string; // HTTP Request node v2
domain_path?: string; // HTTP Request node v2
position: [number, number]; position: [number, number];
mode?: string; mode?: string;
credential_type?: string; // HTTP Request node v2
credential_set?: boolean; // HTTP Request node v2
method?: string; // HTTP Request node v2
} }
export interface INodeNameIndex { export interface INodeNameIndex {

View file

@ -60,6 +60,59 @@ function areOverlapping(
); );
} }
const URL_PARTS_REGEX = /(?<protocolPlusDomain>.*?\..*?)(?<pathname>\/.*)/;
export function getDomainBase(raw: string, urlParts = URL_PARTS_REGEX): string {
try {
const url = new URL(raw);
return [url.protocol, url.hostname].join('//');
} catch (_) {
const match = urlParts.exec(raw);
if (!match?.groups?.protocolPlusDomain) return '';
return match.groups.protocolPlusDomain;
}
}
function isSensitive(segment: string) {
if (/^v\d+$/.test(segment)) return false;
return /%40/.test(segment) || /\d/.test(segment) || /^[0-9A-F]{8}/i.test(segment);
}
export const ANONYMIZATION_CHARACTER = '*';
function sanitizeRoute(raw: string, check = isSensitive, char = ANONYMIZATION_CHARACTER) {
return raw
.split('/')
.map((segment) => (check(segment) ? char.repeat(segment.length) : segment))
.join('/');
}
/**
* Return pathname plus query string from URL, anonymizing IDs in route and query params.
*/
export function getDomainPath(raw: string, urlParts = URL_PARTS_REGEX): string {
try {
const url = new URL(raw);
if (!url.hostname) throw new Error('Malformed URL');
return sanitizeRoute(url.pathname);
} catch (_) {
const match = urlParts.exec(raw);
if (!match?.groups?.pathname) return '';
// discard query string
const route = match.groups.pathname.split('?').shift() as string;
return sanitizeRoute(route);
}
}
export function generateNodesGraph( export function generateNodesGraph(
workflow: IWorkflowBase, workflow: IWorkflowBase,
nodeTypes: INodeTypes, nodeTypes: INodeTypes,
@ -100,12 +153,30 @@ export function generateNodesGraph(
position: node.position, position: node.position,
}; };
if (node.type === 'n8n-nodes-base.httpRequest') { if (node.type === 'n8n-nodes-base.httpRequest' && node.typeVersion === 1) {
try { try {
nodeItem.domain = new URL(node.parameters.url as string).hostname; nodeItem.domain = new URL(node.parameters.url as string).hostname;
} catch (e) { } catch (_) {
nodeItem.domain = node.parameters.url as string; nodeItem.domain = getDomainBase(node.parameters.url as string);
} }
} else if (node.type === 'n8n-nodes-base.httpRequest' && node.typeVersion === 2) {
const { authentication } = node.parameters as { authentication: string };
nodeItem.credential_type = {
none: 'none',
genericCredentialType: node.parameters.genericAuthType as string,
existingCredentialType: node.parameters.nodeCredentialType as string,
}[authentication];
nodeItem.credential_set = node.credentials
? Object.keys(node.credentials).length > 0
: false;
const { url } = node.parameters as { url: string };
nodeItem.domain_base = getDomainBase(url);
nodeItem.domain_path = getDomainPath(url);
nodeItem.method = node.parameters.requestMethod as string;
} else { } else {
const nodeType = nodeTypes.getByNameAndVersion(node.type); const nodeType = nodeTypes.getByNameAndVersion(node.type);

View file

@ -0,0 +1,191 @@
import { v5 as uuidv5, v3 as uuidv3, v4 as uuidv4, v1 as uuidv1 } from 'uuid';
import {
ANONYMIZATION_CHARACTER as CHAR,
getDomainBase,
getDomainPath,
} from '../src/TelemetryHelpers';
describe('getDomainBase should return protocol plus domain', () => {
test('in valid URLs', () => {
for (const url of validUrls(numericId)) {
const { full, protocolPlusDomain } = url;
expect(getDomainBase(full)).toBe(protocolPlusDomain);
}
});
test('in malformed URLs', () => {
for (const url of malformedUrls(numericId)) {
const { full, protocolPlusDomain } = url;
expect(getDomainBase(full)).toBe(protocolPlusDomain);
}
});
});
describe('getDomainPath should return pathname, excluding query string', () => {
describe('anonymizing strings containing at least one number', () => {
test('in valid URLs', () => {
for (const url of validUrls(alphanumericId)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
test('in malformed URLs', () => {
for (const url of malformedUrls(alphanumericId)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
});
describe('anonymizing UUIDs', () => {
test('in valid URLs', () => {
for (const url of uuidUrls(validUrls)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
test('in malformed URLs', () => {
for (const url of uuidUrls(malformedUrls)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
});
describe('anonymizing emails', () => {
test('in valid URLs', () => {
for (const url of validUrls(email)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
test('in malformed URLs', () => {
for (const url of malformedUrls(email)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
});
});
function validUrls(idMaker: typeof alphanumericId | typeof email, char = CHAR) {
const firstId = idMaker();
const secondId = idMaker();
const firstIdObscured = char.repeat(firstId.length);
const secondIdObscured = char.repeat(secondId.length);
return [
{
full: `https://test.com/api/v1/users/${firstId}`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}`,
},
{
full: `https://test.com/api/v1/users/${firstId}/`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `https://test.com/api/v1/users?id=${firstId}`,
protocolPlusDomain: 'https://test.com',
pathname: '/api/v1/users',
},
{
full: `https://test.com/api/v1/users?id=${firstId}&post=${secondId}`,
protocolPlusDomain: 'https://test.com',
pathname: '/api/v1/users',
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}`,
},
];
}
function malformedUrls(idMaker: typeof numericId | typeof email, char = CHAR) {
const firstId = idMaker();
const secondId = idMaker();
const firstIdObscured = char.repeat(firstId.length);
const secondIdObscured = char.repeat(secondId.length);
return [
{
full: `test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `htp://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'htp://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `test.com/api/v1/users?id=${firstId}`,
protocolPlusDomain: 'test.com',
pathname: '/api/v1/users',
},
{
full: `test.com/api/v1/users?id=${firstId}&post=${secondId}`,
protocolPlusDomain: 'test.com',
pathname: '/api/v1/users',
},
];
}
const email = () => encodeURIComponent('test@test.com');
function uuidUrls(
urlsMaker: typeof validUrls | typeof malformedUrls,
baseName = 'test',
namespaceUuid = uuidv4(),
) {
return [
...urlsMaker(() => uuidv5(baseName, namespaceUuid)),
...urlsMaker(uuidv4),
...urlsMaker(() => uuidv3(baseName, namespaceUuid)),
...urlsMaker(uuidv1),
];
}
function digit() {
return Math.floor(Math.random() * 10);
}
function positiveDigit(): number {
const d = digit();
return d === 0 ? positiveDigit() : d;
}
function numericId(length = positiveDigit()) {
return Array.from({ length }, digit).join('');
}
function alphanumericId() {
return chooseRandomly([
`john${numericId()}`,
`title${numericId(1)}`,
numericId(),
]);
}
const chooseRandomly = <T>(array: T[]) => array[Math.floor(Math.random() * array.length)];