Skip to content

Commit 40aa8ad

Browse files
authored
feat(crunchbase): add Crunchbase Data API integration (#6875)
* feat(crunchbase): add Crunchbase Data API integration Covers the v4 Data API end to end: dedicated search and lookup operations for organizations, people, funding rounds, and acquisitions, plus generic collection-parameterized search and lookup reaching the remaining 39 collections, single-card paging, autocomplete, the deleted-entity feed, and fields metadata. Adds a crunchbase-errors extractor: the API answers failures with a bare JSON array, which no existing extractor reads, so an auth or predicate failure would have reported only its HTTP status. * fix(crunchbase): honor card paging limits and cursor exclusivity - Cap a card page at the documented 100-item maximum instead of Search's 1000, which the shared Limit field made easy to carry over - Always request the card's identifier so a narrowed cardFieldIds cannot return a full page with a null cursor and stall a paging loop - Reject the mutually-exclusive afterId/beforeId pair on the card and deleted-entity endpoints, not just on search - Report an unexpected card shape as empty rather than wrapping the envelope as a one-row page
1 parent eede9a9 commit 40aa8ad

33 files changed

Lines changed: 3907 additions & 22 deletions

apps/docs/components/icons.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1489,15 +1489,15 @@ export function InstagramIcon(props: SVGProps<SVGSVGElement>) {
14891489

14901490
export function CrunchbaseIcon(props: SVGProps<SVGSVGElement>) {
14911491
return (
1492-
<svg
1493-
{...props}
1494-
fill='currentColor'
1495-
width='24'
1496-
height='24'
1497-
viewBox='0 0 24 24'
1498-
xmlns='http://www.w3.org/2000/svg'
1499-
>
1500-
<path d='M21.6 0H2.4A2.41 2.41 0 0 0 0 2.4v19.2A2.41 2.41 0 0 0 2.4 24h19.2a2.41 2.41 0 0 0 2.4-2.4V2.4A2.41 2.41 0 0 0 21.6 0zM7.05 14.47A2.11 2.11 0 0 0 9.84 13.42h1.66a3.69 3.69 0 1 1 0-1.75H9.84a2.11 2.11 0 1 0-2.79 2.8zm11.35.845a3.55 3.55 0 0 1-1.06.63 3.68 3.68 0 0 1-3.39-.38v.38h-1.51V5.37h1.5v4.11a3.74 3.74 0 0 1 1.8-.63H16a3.67 3.67 0 0 1 2.39 6.46zm-.223-2.77a2.1 2.1 0 1 1-4.21 0 2.1 2.1 0 0 1 4.21 0z' />
1492+
<svg {...props} viewBox='0 0 32 32' fill='none' xmlns='http://www.w3.org/2000/svg'>
1493+
<path
1494+
d='M28.802 0h-25.604c-1.76 0.005-3.193 1.438-3.198 3.198v25.604c0.005 1.76 1.438 3.193 3.198 3.198h25.604c1.76-0.005 3.193-1.438 3.198-3.198v-25.604c-0.005-1.76-1.438-3.193-3.198-3.198z'
1495+
fill='#ffffff'
1496+
/>
1497+
<path
1498+
d='M28.802 0h-25.604c-1.76 0.005-3.193 1.438-3.198 3.198v25.604c0.005 1.76 1.438 3.193 3.198 3.198h25.604c1.76-0.005 3.193-1.438 3.198-3.198v-25.604c-0.005-1.76-1.438-3.193-3.198-3.198zM9.396 19.286c1.411 0.646 3.078 0.021 3.724-1.391h2.214c-1.38 5.651-9.698 4.651-9.698-1.167 0-5.823 8.318-6.823 9.698-1.167h-2.214c-0.813-1.786-3.161-2.214-4.547-0.823-1.391 1.385-0.964 3.734 0.823 4.547zM24.521 20.411c-0.422 0.365-0.896 0.646-1.417 0.844-1.495 0.578-3.182 0.391-4.516-0.51v0.51h-2.016v-14.094h2v5.479c0.714-0.484 1.542-0.771 2.401-0.839h0.359c4.552-0.010 6.646 5.656 3.188 8.609zM24.224 16.724c0.031 1.573-1.234 2.87-2.807 2.87s-2.839-1.297-2.802-2.87c0.078-3.656 5.526-3.656 5.609 0z'
1499+
fill='#0287d1'
1500+
/>
15011501
</svg>
15021502
)
15031503
}

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
ContextDevIcon,
4848
ConvexIcon,
4949
CrowdStrikeIcon,
50+
CrunchbaseIcon,
5051
CursorIcon,
5152
DagsterIcon,
5253
DatabricksIcon,
@@ -307,6 +308,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
307308
context_dev: ContextDevIcon,
308309
convex: ConvexIcon,
309310
crowdstrike: CrowdStrikeIcon,
311+
crunchbase: CrunchbaseIcon,
310312
cursor: CursorIcon,
311313
cursor_v2: CursorIcon,
312314
dagster: DagsterIcon,

apps/docs/content/docs/en/integrations/crunchbase.mdx

Lines changed: 369 additions & 0 deletions
Large diffs are not rendered by default.

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"context_dev",
4949
"convex",
5050
"crowdstrike",
51+
"crunchbase",
5152
"cursor",
5253
"dagster",
5354
"databricks",
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { CrunchbaseBlock } from '@/blocks/blocks/crunchbase'
6+
7+
/**
8+
* Every assertion here runs against `{ ...inputs, ...buildParams(inputs) }`, the
9+
* shape the generic tool handler actually forwards. A key the mapper omits is
10+
* *not* dropped by that merge — the raw subBlock value survives — so asserting
11+
* on the mapper's return alone would prove nothing about what the tool receives.
12+
*/
13+
describe('CrunchbaseBlock', () => {
14+
const buildParams = CrunchbaseBlock.tools.config!.params!
15+
const selectTool = CrunchbaseBlock.tools.config!.tool!
16+
17+
const resolve = (inputs: Record<string, unknown>) => ({ ...inputs, ...buildParams(inputs) })
18+
19+
const operationIds =
20+
CrunchbaseBlock.subBlocks
21+
.find((subBlock) => subBlock.id === 'operation')
22+
?.options?.map((option) => (option as { id: string }).id) ?? []
23+
24+
it('maps every dropdown operation onto a registered tool', () => {
25+
expect(operationIds).toHaveLength(14)
26+
expect(new Set(operationIds.map((id) => selectTool({ operation: id })))).toEqual(
27+
new Set(CrunchbaseBlock.tools.access)
28+
)
29+
})
30+
31+
it('rejects an operation the dropdown does not offer', () => {
32+
expect(() => selectTool({ operation: 'search_unicorns' })).toThrow(
33+
/Invalid Crunchbase operation/
34+
)
35+
})
36+
37+
it('gives every subblock a unique id', () => {
38+
const ids = CrunchbaseBlock.subBlocks.map((subBlock) => subBlock.id)
39+
expect(ids).toHaveLength(new Set(ids).size)
40+
})
41+
42+
it('shows a subblock for every operation that owns it', () => {
43+
const conditionsFor = (id: string) => {
44+
const condition = CrunchbaseBlock.subBlocks.find((subBlock) => subBlock.id === id)?.condition
45+
const value = (condition as { value?: unknown } | undefined)?.value
46+
return new Set(Array.isArray(value) ? value.map(String) : [String(value)])
47+
}
48+
49+
expect(conditionsFor('searchQuery')).toEqual(
50+
new Set([
51+
'search_organizations',
52+
'search_people',
53+
'search_funding_rounds',
54+
'search_acquisitions',
55+
'search_entities',
56+
])
57+
)
58+
expect(conditionsFor('entityId')).toEqual(
59+
new Set([
60+
'get_organization',
61+
'get_person',
62+
'get_funding_round',
63+
'get_acquisition',
64+
'get_entity',
65+
'get_entity_card',
66+
])
67+
)
68+
})
69+
70+
it('never hides a required field behind advanced mode', () => {
71+
const advancedRequired = CrunchbaseBlock.subBlocks
72+
.filter((subBlock) => subBlock.mode === 'advanced' && subBlock.required)
73+
.map((subBlock) => subBlock.id)
74+
75+
expect(advancedRequired).toEqual([])
76+
})
77+
78+
/*
79+
* The mapper's whole job is dropping keys that belong to another operation.
80+
* `shouldSerializeSubBlock` short-circuits on `mode: 'advanced'` before it
81+
* evaluates a condition, so a hidden advanced field still reaches `inputs` —
82+
* only an explicit `undefined` removes it from what goes out on the wire.
83+
*/
84+
it('drops a previous operation’s leftovers when the operation changes', () => {
85+
const params = resolve({
86+
operation: 'autocomplete',
87+
apiKey: 'key',
88+
autocompleteQuery: 'airbnb',
89+
searchQuery: '[{"type":"predicate","field_id":"categories"}]',
90+
searchFieldIds: '["identifier"]',
91+
entityId: 'tesla-motors',
92+
fieldIds: '["identifier","name"]',
93+
cardIds: '["founders"]',
94+
order: '[{"field_id":"rank_org","sort":"asc"}]',
95+
afterId: 'stale-cursor',
96+
collection: 'organizations',
97+
})
98+
99+
expect(params.query).toBe('airbnb')
100+
expect(params.entityId).toBeUndefined()
101+
expect(params.fieldIds).toBeUndefined()
102+
expect(params.cardIds).toBeUndefined()
103+
expect(params.order).toBeUndefined()
104+
expect(params.afterId).toBeUndefined()
105+
expect(params.collection).toBeUndefined()
106+
expect(params.searchQuery).toBeUndefined()
107+
expect(params.autocompleteQuery).toBeUndefined()
108+
})
109+
110+
it('routes the predicate query to a search and the text query to autocomplete', () => {
111+
const search = resolve({
112+
operation: 'search_organizations',
113+
apiKey: 'key',
114+
searchQuery: '[{"type":"predicate","field_id":"categories","operator_id":"includes"}]',
115+
autocompleteQuery: 'airbnb',
116+
})
117+
expect(search.query).toBe(
118+
'[{"type":"predicate","field_id":"categories","operator_id":"includes"}]'
119+
)
120+
121+
const autocomplete = resolve({
122+
operation: 'autocomplete',
123+
apiKey: 'key',
124+
searchQuery: '[{"type":"predicate","field_id":"categories","operator_id":"includes"}]',
125+
autocompleteQuery: 'airbnb',
126+
})
127+
expect(autocomplete.query).toBe('airbnb')
128+
})
129+
130+
it('feeds the generic search its own required field list', () => {
131+
const generic = resolve({
132+
operation: 'search_entities',
133+
apiKey: 'key',
134+
collection: 'events',
135+
searchQuery: '[]',
136+
searchFieldIds: '["identifier","short_description"]',
137+
fieldIds: '["identifier","name"]',
138+
})
139+
expect(generic.fieldIds).toBe('["identifier","short_description"]')
140+
expect(generic.searchFieldIds).toBeUndefined()
141+
142+
const specific = resolve({
143+
operation: 'search_organizations',
144+
apiKey: 'key',
145+
searchQuery: '[]',
146+
searchFieldIds: '["identifier","short_description"]',
147+
fieldIds: '["identifier","name"]',
148+
})
149+
expect(specific.fieldIds).toBe('["identifier","name"]')
150+
})
151+
152+
it('picks the collection each operation actually asks for', () => {
153+
const card = resolve({
154+
operation: 'get_entity_card',
155+
apiKey: 'key',
156+
entityId: 'sequoia-capital',
157+
cardId: 'participated_investments',
158+
cardCollection: 'organizations',
159+
collection: 'events',
160+
deletedCollection: 'people',
161+
})
162+
expect(card.collection).toBe('organizations')
163+
expect(card.cardCollection).toBeUndefined()
164+
165+
const deleted = resolve({
166+
operation: 'list_deleted_entities',
167+
apiKey: 'key',
168+
cardCollection: 'organizations',
169+
collection: 'events',
170+
deletedCollection: 'people',
171+
})
172+
expect(deleted.collection).toBe('people')
173+
expect(deleted.deletedCollection).toBeUndefined()
174+
})
175+
})

0 commit comments

Comments
 (0)