pipedrive-connect 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +1 -0
- data/.rubocop.yml +71 -0
- data/.vscode/settings.json +8 -0
- data/CHANGELOG.md +39 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +13 -0
- data/LICENSE +21 -0
- data/README.md +182 -0
- data/Rakefile +4 -0
- data/bin/console +16 -0
- data/bin/setup +8 -0
- data/lib/pipedrive/api_operations/create.rb +16 -0
- data/lib/pipedrive/api_operations/delete.rb +11 -0
- data/lib/pipedrive/api_operations/request.rb +54 -0
- data/lib/pipedrive/api_operations/update.rb +16 -0
- data/lib/pipedrive/errors.rb +22 -0
- data/lib/pipedrive/fields.rb +37 -0
- data/lib/pipedrive/merge.rb +14 -0
- data/lib/pipedrive/resource.rb +155 -0
- data/lib/pipedrive/resources/activity.rb +8 -0
- data/lib/pipedrive/resources/activity_type.rb +7 -0
- data/lib/pipedrive/resources/call_log.rb +7 -0
- data/lib/pipedrive/resources/currency.rb +7 -0
- data/lib/pipedrive/resources/deal.rb +26 -0
- data/lib/pipedrive/resources/file.rb +5 -0
- data/lib/pipedrive/resources/filter.rb +5 -0
- data/lib/pipedrive/resources/global_message.rb +7 -0
- data/lib/pipedrive/resources/goal.rb +5 -0
- data/lib/pipedrive/resources/lead.rb +5 -0
- data/lib/pipedrive/resources/lead_label.rb +7 -0
- data/lib/pipedrive/resources/lead_source.rb +7 -0
- data/lib/pipedrive/resources/note.rb +7 -0
- data/lib/pipedrive/resources/organization.rb +12 -0
- data/lib/pipedrive/resources/person.rb +8 -0
- data/lib/pipedrive/resources/pipeline.rb +5 -0
- data/lib/pipedrive/resources/product.rb +7 -0
- data/lib/pipedrive/resources/stage.rb +5 -0
- data/lib/pipedrive/resources/team.rb +5 -0
- data/lib/pipedrive/resources/user.rb +5 -0
- data/lib/pipedrive/resources/webhook.rb +5 -0
- data/lib/pipedrive/resources.rb +23 -0
- data/lib/pipedrive/util.rb +25 -0
- data/lib/pipedrive/version.rb +5 -0
- data/lib/pipedrive.rb +34 -0
- data/pipedrive-connect.gemspec +33 -0
- metadata +105 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: b4da043a8e3905c7fb4e906bff586866892f698168956953f90d5cac1efb74df
|
4
|
+
data.tar.gz: 2a7e1f744337017cc62ca332ccf9099ae3dbabe2a2a3fb186dc80915593b89a3
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 7d4c23b5883caa37c3b5e9f111331ba42696f4db5ab93c261668ed6494ab2542c8ab0d9b9a7cf0319b73ba73e27ad079230db5da9bf047fd684ca61d6d2b430f
|
7
|
+
data.tar.gz: 74ada44b6ac31a5d28569c7eb004d13da26c92e13c2934c80ee984160f1fe7fbfed2d6e5fe7dab8f3d59600e022df5ef04d637a1745424b9755766567f7847cc
|
data/.gitignore
ADDED
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--require spec_helper
|
data/.rubocop.yml
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
AllCops:
|
2
|
+
NewCops: enable
|
3
|
+
|
4
|
+
DisplayCopNames: true
|
5
|
+
TargetRubyVersion: 2.6
|
6
|
+
|
7
|
+
Metrics/BlockLength:
|
8
|
+
Max: 40
|
9
|
+
Exclude:
|
10
|
+
# `context` in tests are blocks and get quite large, so exclude the test
|
11
|
+
# directory from having to adhere to this rule.
|
12
|
+
- "spec/**/*.rb"
|
13
|
+
|
14
|
+
Metrics/ClassLength:
|
15
|
+
Max: 200
|
16
|
+
Exclude:
|
17
|
+
# Test classes get quite large, so exclude the test directory from having
|
18
|
+
# to adhere to this rule.
|
19
|
+
- "test/**/*.rb"
|
20
|
+
|
21
|
+
Metrics/MethodLength:
|
22
|
+
Max: 30
|
23
|
+
|
24
|
+
Metrics/ModuleLength:
|
25
|
+
Enabled: false
|
26
|
+
|
27
|
+
Metrics/AbcSize:
|
28
|
+
Max: 51
|
29
|
+
|
30
|
+
# Offense count: 12
|
31
|
+
Metrics/CyclomaticComplexity:
|
32
|
+
Max: 15
|
33
|
+
|
34
|
+
# Offense count: 6
|
35
|
+
# Configuration parameters: CountKeywordArgs.
|
36
|
+
Metrics/ParameterLists:
|
37
|
+
Max: 7
|
38
|
+
|
39
|
+
# Offense count: 8
|
40
|
+
Metrics/PerceivedComplexity:
|
41
|
+
Max: 17
|
42
|
+
|
43
|
+
Style/AccessModifierDeclarations:
|
44
|
+
EnforcedStyle: inline
|
45
|
+
|
46
|
+
Style/FrozenStringLiteralComment:
|
47
|
+
EnforcedStyle: always
|
48
|
+
|
49
|
+
Style/NumericPredicate:
|
50
|
+
Enabled: false
|
51
|
+
|
52
|
+
Style/StringLiterals:
|
53
|
+
EnforcedStyle: double_quotes
|
54
|
+
|
55
|
+
Style/TrailingCommaInArrayLiteral:
|
56
|
+
EnforcedStyleForMultiline: consistent_comma
|
57
|
+
|
58
|
+
Style/TrailingCommaInHashLiteral:
|
59
|
+
EnforcedStyleForMultiline: consistent_comma
|
60
|
+
# Offense count: 23
|
61
|
+
|
62
|
+
# Offense count: 86
|
63
|
+
Style/Documentation:
|
64
|
+
Enabled: false
|
65
|
+
|
66
|
+
Naming/PredicateName:
|
67
|
+
Enabled: true
|
68
|
+
AllowedMethods: has_many
|
69
|
+
|
70
|
+
Layout/LineLength:
|
71
|
+
Max: 90
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
# Changelog
|
2
|
+
|
3
|
+
This file contains all notable changes to this project.
|
4
|
+
This project adheres to [Semantic Versioning](http://semver.org/).
|
5
|
+
This change log follows the conventions of [keepachangelog.com](http://keepachangelog.com/).
|
6
|
+
|
7
|
+
## [1.2.4] - 2021-09-13
|
8
|
+
|
9
|
+
- Add `Pipedrive.debug_http_body`for debugging the http payload
|
10
|
+
- Fix bugs with `POST`/`PUT` endpoints
|
11
|
+
## [1.2.3] - 2021-09-10
|
12
|
+
|
13
|
+
- Add `Pipedrive.debug` so basic debug info is not displayed out of the box.
|
14
|
+
- Add `Pipedrive.debug_http` for debugging http traffic.
|
15
|
+
- Fix bug in some resources when no fields filtering was provided.
|
16
|
+
|
17
|
+
## [1.2.2] - 2021-05-11
|
18
|
+
|
19
|
+
- Fix bug introduced by v1.2.0 where `has_many` removed the chance to pass extra parameters.
|
20
|
+
|
21
|
+
## [1.2.1] - 2021-05-10
|
22
|
+
|
23
|
+
- Some endpoints like `deals/:id/products` allow to expand the response with `include_product_data` that include an attr `product` with all the data - including the custon fields - of the products. The deafult for that option is `false` or `0`. I personally think this is redundant, so this version overrides this behavior by passing `true` or `1` and deleging the attr `product` by merging its content with the body itself, at the end `/products` should return `products`. On future versions this option would be passed to the `has_many` method, like `has_many :products, class_name: "Product", include_data: false`
|
24
|
+
|
25
|
+
## [1.2.0] - 2021-06-05
|
26
|
+
|
27
|
+
- Add Lead, LeadLabel, LeadLabel and Goal models.
|
28
|
+
|
29
|
+
## [1.1.0] - 2020-09-07
|
30
|
+
|
31
|
+
- Add capability to merge organizations, people and deals. See [the doc here](https://developers.pipedrive.com/docs/api/v1/#!/Organizations/put_organizations_id_merge).
|
32
|
+
|
33
|
+
## [1.0.1] - 2020-07-07
|
34
|
+
|
35
|
+
- Fixes unitialized constant error when the class_name within has_many definition doesn't contain the namespace
|
36
|
+
|
37
|
+
## [1.0] - 2020-06-25
|
38
|
+
|
39
|
+
- Initial release
|
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
2
|
+
|
3
|
+
## Our Pledge
|
4
|
+
|
5
|
+
In the interest of fostering an open and welcoming environment, we as
|
6
|
+
contributors and maintainers pledge to making participation in our project and
|
7
|
+
our community a harassment-free experience for everyone, regardless of age, body
|
8
|
+
size, disability, ethnicity, gender identity and expression, level of experience,
|
9
|
+
nationality, personal appearance, race, religion, or sexual identity and
|
10
|
+
orientation.
|
11
|
+
|
12
|
+
## Our Standards
|
13
|
+
|
14
|
+
Examples of behavior that contributes to creating a positive environment
|
15
|
+
include:
|
16
|
+
|
17
|
+
- Using welcoming and inclusive language
|
18
|
+
- Being respectful of differing viewpoints and experiences
|
19
|
+
- Gracefully accepting constructive criticism
|
20
|
+
- Focusing on what is best for the community
|
21
|
+
- Showing empathy towards other community members
|
22
|
+
|
23
|
+
Examples of unacceptable behavior by participants include:
|
24
|
+
|
25
|
+
- The use of sexualized language or imagery and unwelcome sexual attention or
|
26
|
+
advances
|
27
|
+
- Trolling, insulting/derogatory comments, and personal or political attacks
|
28
|
+
- Public or private harassment
|
29
|
+
- Publishing others' private information, such as a physical or electronic
|
30
|
+
address, without explicit permission
|
31
|
+
- Other conduct which could reasonably be considered inappropriate in a
|
32
|
+
professional setting
|
33
|
+
|
34
|
+
## Our Responsibilities
|
35
|
+
|
36
|
+
Project maintainers are responsible for clarifying the standards of acceptable
|
37
|
+
behavior and are expected to take appropriate and fair corrective action in
|
38
|
+
response to any instances of unacceptable behavior.
|
39
|
+
|
40
|
+
Project maintainers have the right and responsibility to remove, edit, or
|
41
|
+
reject comments, commits, code, wiki edits, issues, and other contributions
|
42
|
+
that are not aligned to this Code of Conduct, or to ban temporarily or
|
43
|
+
permanently any contributor for other behaviors that they deem inappropriate,
|
44
|
+
threatening, offensive, or harmful.
|
45
|
+
|
46
|
+
## Scope
|
47
|
+
|
48
|
+
This Code of Conduct applies both within project spaces and in public spaces
|
49
|
+
when an individual is representing the project or its community. Examples of
|
50
|
+
representing a project or community include using an official project e-mail
|
51
|
+
address, posting via an official social media account, or acting as an appointed
|
52
|
+
representative at an online or offline event. Representation of a project may be
|
53
|
+
further defined and clarified by project maintainers.
|
54
|
+
|
55
|
+
## Enforcement
|
56
|
+
|
57
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
58
|
+
reported by contacting the project team at team@getonbrd.com. All
|
59
|
+
complaints will be reviewed and investigated and will result in a response that
|
60
|
+
is deemed necessary and appropriate to the circumstances. The project team is
|
61
|
+
obligated to maintain confidentiality with regard to the reporter of an incident.
|
62
|
+
Further details of specific enforcement policies may be posted separately.
|
63
|
+
|
64
|
+
Project maintainers who do not follow or enforce the Code of Conduct in good
|
65
|
+
faith may face temporary or permanent repercussions as determined by other
|
66
|
+
members of the project's leadership.
|
67
|
+
|
68
|
+
## Attribution
|
69
|
+
|
70
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
71
|
+
available at [https://contributor-covenant.org/version/1/4][version]
|
72
|
+
|
73
|
+
[homepage]: https://contributor-covenant.org
|
74
|
+
[version]: https://contributor-covenant.org/version/1/4/
|
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2020 Get on Board (https://www.getonbrd.com)
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,182 @@
|
|
1
|
+
# Pipedrive API Ruby library
|
2
|
+
|
3
|
+
Pipedrive::Connect provides a convenient access to the Pipedrive API from applications written in the Ruby language.
|
4
|
+
|
5
|
+
It abstracts the developer from having to deal with API requests by mapping the list of the core resources like Organization, Person or Deal to ruby classes and objects.
|
6
|
+
|
7
|
+
Key features:
|
8
|
+
|
9
|
+
- Easy to setup.
|
10
|
+
- Map core concepts in Pipedrive to ruby classes.
|
11
|
+
- Allow access to relationships among these concepts like accessing all the persons within an organization or its deals.
|
12
|
+
- Abstract the developer from having to deal with custom fields by doing it internally.
|
13
|
+
|
14
|
+
## Documentation
|
15
|
+
|
16
|
+
Check the original API doc at: https://pipedrive.readme.io/
|
17
|
+
|
18
|
+
## Installation
|
19
|
+
|
20
|
+
Add this line to your application's Gemfile:
|
21
|
+
|
22
|
+
```ruby
|
23
|
+
gem "pipedrive-connect", github: "getonbrd/pipedrive-connect"
|
24
|
+
```
|
25
|
+
|
26
|
+
then execute:
|
27
|
+
|
28
|
+
```sh
|
29
|
+
$ bundle install
|
30
|
+
```
|
31
|
+
|
32
|
+
Or install it yourself as:
|
33
|
+
|
34
|
+
```sh
|
35
|
+
$ gem install pipedrive-connect
|
36
|
+
```
|
37
|
+
|
38
|
+
## Usage
|
39
|
+
|
40
|
+
Configure the library by initializing it with the **api key** you can find in your [setttings page at Pipedrive](https://yourcompany.pipedrive.com/settings/api).
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
Pipedrive.api_key = "abc123"
|
44
|
+
```
|
45
|
+
|
46
|
+
In case of using rails, do it via an initalizer:
|
47
|
+
|
48
|
+
```ruby
|
49
|
+
# file: app/initializers/pipedrive.rb
|
50
|
+
|
51
|
+
require 'pipedrive'
|
52
|
+
|
53
|
+
Pipedrive.api_key = ENV["PIPEDRIVE_API_KEY"]
|
54
|
+
```
|
55
|
+
|
56
|
+
### Models
|
57
|
+
|
58
|
+
Access your data in pipedrive via the models (for the complete list check out the directory `lib/pipedrive/resources`). You'll find that most of these classes are documented in the [API Reference](https://developers.pipedrive.com/docs/api/v1/).
|
59
|
+
|
60
|
+
For example to search, retrieve, access, create, update or delete an organization:
|
61
|
+
|
62
|
+
```ruby
|
63
|
+
# search for organizations with the term "Acme Inc" in any of their fields
|
64
|
+
# return an array of Pipedrive::Organization instances
|
65
|
+
orgs = Pipedrive::Organization.search("Acme Inc")
|
66
|
+
|
67
|
+
# specify it is an exact match and reduce the scope to name and address
|
68
|
+
orgs = Pipedrive::Organization.search(
|
69
|
+
"Acme Inc",
|
70
|
+
extact_match: true,
|
71
|
+
fields: [:name, :address]
|
72
|
+
)
|
73
|
+
|
74
|
+
# Want to paginate across all the organizations sorting them by name?
|
75
|
+
orgs = Pipedrive::Organization.all(
|
76
|
+
"Acme Inc",
|
77
|
+
start: 0,
|
78
|
+
limit: 100,
|
79
|
+
sort: :name
|
80
|
+
)
|
81
|
+
|
82
|
+
# if you know the id then retrieve the org
|
83
|
+
acme = Pipedrive::Organization.retrieve(123)
|
84
|
+
acme.name
|
85
|
+
|
86
|
+
# get access to the activities, deals and persons of the org
|
87
|
+
acme.activities
|
88
|
+
acme.deals
|
89
|
+
acme.persons
|
90
|
+
|
91
|
+
# create a new one
|
92
|
+
new_branch = Pipedrive::Organization.create(name: "New Acme Inc")
|
93
|
+
# update the name
|
94
|
+
new_acme.update(name: "Acme the new Inc")
|
95
|
+
|
96
|
+
# ot simply delete it
|
97
|
+
new_acme.delete
|
98
|
+
```
|
99
|
+
|
100
|
+
### Custom Fields
|
101
|
+
|
102
|
+
Pipedrive gives you the chance to add additional data by creating custom fields that are not included by default. Deals, Persons, Organizations and Products can all contain custom fields.
|
103
|
+
|
104
|
+
[See the doc here](https://pipedrive.readme.io/docs/core-api-concepts-custom-fields).
|
105
|
+
|
106
|
+
The issue with accessing a custom field via the API is that you have to know the assigned key, for instance, let's say we added a custom field called `domain` of type _text_ to the `Organization` in Pipedrive. If we would want to add a new organization using the API, this "would" be the code:
|
107
|
+
|
108
|
+
```ruby
|
109
|
+
org = Pipedrive::Organization.create(
|
110
|
+
name: "Acme Inc",
|
111
|
+
"sab55f505ca47dda0b4811f9ea5df00020540b80": "acme.com"
|
112
|
+
)
|
113
|
+
```
|
114
|
+
|
115
|
+
Yeah, we know what you are thinking, not convinient at all. Well, we definitely think the same, so we fixed it by abstracting us (the devs) from having to know such key.
|
116
|
+
|
117
|
+
So, using Pipedrive::Connect it is as it should always be:
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
org = Pipedrive::Organization.create(
|
121
|
+
name: "Acme Inc",
|
122
|
+
domain: "acme.com"
|
123
|
+
)
|
124
|
+
|
125
|
+
# By the way, in case you are curious and want to know what all the fields
|
126
|
+
# within an Organization are, just call:
|
127
|
+
org.fields
|
128
|
+
|
129
|
+
# or, this works too
|
130
|
+
Pipedrive::Organization.fields
|
131
|
+
```
|
132
|
+
|
133
|
+
## Debuging
|
134
|
+
|
135
|
+
Show basic debugging info:
|
136
|
+
|
137
|
+
```ruby
|
138
|
+
Pipedrive.debug = true
|
139
|
+
```
|
140
|
+
|
141
|
+
show extended HTTP traffic information:
|
142
|
+
|
143
|
+
```ruby
|
144
|
+
Pipedrive.debug_http = true
|
145
|
+
|
146
|
+
# and to also show the body payloads
|
147
|
+
Pipedrive.debug_http_body = true
|
148
|
+
```
|
149
|
+
|
150
|
+
## Development
|
151
|
+
|
152
|
+
Run the set up:
|
153
|
+
|
154
|
+
```sh
|
155
|
+
$ bundle
|
156
|
+
```
|
157
|
+
|
158
|
+
Run the specs:
|
159
|
+
|
160
|
+
```sh
|
161
|
+
$ bundle exec rspec
|
162
|
+
```
|
163
|
+
|
164
|
+
Run the linter:
|
165
|
+
|
166
|
+
```sh
|
167
|
+
$ bundle exec rubocop
|
168
|
+
```
|
169
|
+
|
170
|
+
## Contributing
|
171
|
+
|
172
|
+
1. Fork it.
|
173
|
+
1. Create your feature branch (git checkout -b my-new-feature).
|
174
|
+
1. Commit your changes (git commit -am 'Add some feature').
|
175
|
+
1. Push to the branch (git push origin my-new-feature).
|
176
|
+
1. Create a new Pull Request.
|
177
|
+
|
178
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/getonbrd/pipedrive-connect. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/getonbrd/pipedrive-connect/blob/master/CODE_OF_CONDUCT.md).
|
179
|
+
|
180
|
+
## Code of Conduct
|
181
|
+
|
182
|
+
Everyone interacting in the Pipedrive::Connect project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/getonbrd/pipedrive-connect/blob/master/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
# frozen_string_literal: true
|
4
|
+
|
5
|
+
require "bundler/setup"
|
6
|
+
require "pipedrive"
|
7
|
+
|
8
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
9
|
+
# with your gem easier. You can also use a different console, if you like.
|
10
|
+
|
11
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
12
|
+
# require "pry"
|
13
|
+
# Pry.start
|
14
|
+
|
15
|
+
require "irb"
|
16
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pipedrive
|
4
|
+
module APIOperations
|
5
|
+
module Request
|
6
|
+
def self.included(base)
|
7
|
+
base.extend(ClassMethods)
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
def request(method, url, params = {})
|
12
|
+
check_api_key!
|
13
|
+
raise "Not supported method" \
|
14
|
+
unless %i[get post put delete].include?(method)
|
15
|
+
|
16
|
+
Util.debug "#{name} #{method.upcase} #{url}"
|
17
|
+
response = api_client.send(method) do |req|
|
18
|
+
req.url url
|
19
|
+
req.params = { api_token: Pipedrive.api_key }
|
20
|
+
if %i[post put].include?(method)
|
21
|
+
req.body = params.to_json
|
22
|
+
else
|
23
|
+
req.params.merge!(params)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
Util.serialize_response(response)
|
27
|
+
end
|
28
|
+
|
29
|
+
def api_client
|
30
|
+
@api_client = Faraday.new(
|
31
|
+
url: BASE_URL,
|
32
|
+
headers: { "Content-Type": "application/json" }
|
33
|
+
) do |faraday|
|
34
|
+
if Pipedrive.debug_http
|
35
|
+
faraday.response :logger, Pipedrive.logger,
|
36
|
+
bodies: Pipedrive.debug_http_body
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
protected def check_api_key!
|
42
|
+
return if Pipedrive.api_key
|
43
|
+
|
44
|
+
raise AuthenticationError, "No API key provided. " \
|
45
|
+
"Set your API key using 'Pipedrive.api_key = <API-KEY>'"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
protected def request(method, url, params = {})
|
50
|
+
self.class.request(method, url, params)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pipedrive
|
4
|
+
module APIOperations
|
5
|
+
module Update
|
6
|
+
def update(params)
|
7
|
+
response = request(
|
8
|
+
:put,
|
9
|
+
resource_url,
|
10
|
+
search_for_fields(params)
|
11
|
+
)
|
12
|
+
update_attributes(response.dig(:data))
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pipedrive
|
4
|
+
class PipedriveError < StandardError
|
5
|
+
attr_reader :code
|
6
|
+
|
7
|
+
def initialize(message = nil, code = nil)
|
8
|
+
super(message)
|
9
|
+
@message = message
|
10
|
+
@code = code
|
11
|
+
end
|
12
|
+
|
13
|
+
def message
|
14
|
+
code_str = @code.nil? ? "" : "(Status #{@code}) "
|
15
|
+
"#{code_str}#{@message}"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
class SettingError < PipedriveError; end
|
19
|
+
class AuthenticationError < PipedriveError; end
|
20
|
+
class NotFoundError < PipedriveError; end
|
21
|
+
class UnkownAPIError < PipedriveError; end
|
22
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pipedrive
|
4
|
+
module Fields
|
5
|
+
def self.included(base)
|
6
|
+
class << base
|
7
|
+
attr_accessor :fields_url
|
8
|
+
end
|
9
|
+
base.extend(ClassMethods)
|
10
|
+
end
|
11
|
+
|
12
|
+
module ClassMethods
|
13
|
+
def fields
|
14
|
+
url = fields_url || "#{class_name.downcase}Fields"
|
15
|
+
data = request(:get, url).dig(:data)
|
16
|
+
# return a hash prefilled with
|
17
|
+
# the fields hash and name parameterized
|
18
|
+
# and the original array of fields (schema)
|
19
|
+
dicc = data.reduce({}) do |fields_dicc, field|
|
20
|
+
# snakify the name
|
21
|
+
snake_name = field.dig(:name).gsub(/\w+/).reduce([]) do |words, c|
|
22
|
+
words << c.downcase
|
23
|
+
end.join("_")
|
24
|
+
|
25
|
+
fields_dicc.merge(
|
26
|
+
field.dig(:key).to_sym => snake_name.to_sym
|
27
|
+
)
|
28
|
+
end
|
29
|
+
[dicc, data]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def fields
|
34
|
+
self.class.fields
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pipedrive
|
4
|
+
module Merge
|
5
|
+
def merge(with_id:)
|
6
|
+
raise "with_id must be an integer" unless with_id&.is_a?(Integer)
|
7
|
+
|
8
|
+
response = request(:put,
|
9
|
+
"#{resource_url}/merge",
|
10
|
+
merge_with_id: with_id)
|
11
|
+
self.class.new(response.dig(:data))
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,155 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pipedrive
|
4
|
+
class Resource
|
5
|
+
include Pipedrive::APIOperations::Request
|
6
|
+
extend Pipedrive::APIOperations::Create
|
7
|
+
include Pipedrive::APIOperations::Update
|
8
|
+
include Pipedrive::APIOperations::Delete
|
9
|
+
|
10
|
+
class << self
|
11
|
+
attr_accessor :resources_url
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.class_name
|
15
|
+
name.split("::")[-1]
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.resource_url
|
19
|
+
if self == Resource
|
20
|
+
raise NotImplementedError,
|
21
|
+
"Pipedrive::Resource is an abstract class. You should perform actions " \
|
22
|
+
"on its subclasses (Organization, Person, Deal, etc)"
|
23
|
+
end
|
24
|
+
resources_url || "#{class_name.downcase}s"
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.fields_dicc
|
28
|
+
@fields_dicc ||= fields[0] if respond_to? :fields
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.inverted_fields_dicc
|
32
|
+
@inverted_fields_dicc ||= fields_dicc&.invert
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.search_for_fields(values)
|
36
|
+
return values unless values.is_a?(Hash) && fields_dicc&.any?
|
37
|
+
|
38
|
+
values.reduce({}) do |new_hash, (k, v)|
|
39
|
+
if inverted_fields_dicc[k]
|
40
|
+
new_hash.merge(inverted_fields_dicc[k] => v)
|
41
|
+
else
|
42
|
+
new_hash.merge(k => v)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.retrieve(id)
|
48
|
+
response = request(:get, "#{resource_url}/#{id}")
|
49
|
+
new(response.dig(:data))
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.all(params = {})
|
53
|
+
response = request(:get, resource_url, params)
|
54
|
+
response.dig(:data)&.map { |d| new(d) }
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.search(term, params = {})
|
58
|
+
response = request(
|
59
|
+
:get,
|
60
|
+
"#{resource_url}/search",
|
61
|
+
{ term: term }.merge(params)
|
62
|
+
)
|
63
|
+
response.dig(:data, :items).map { |d| new(d.dig(:item)) }
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.has_many(resource_name, class_name:)
|
67
|
+
unless resource_name && class_name
|
68
|
+
raise "You must specify the resource name and its class name " \
|
69
|
+
"For example has_many :deals, class_name: 'Deal'"
|
70
|
+
end
|
71
|
+
class_name_lower_case = class_name.downcase
|
72
|
+
# always include all the data of the resource
|
73
|
+
options = { "include_#{class_name_lower_case}_data": 1 }
|
74
|
+
# add namespace to class_name
|
75
|
+
class_name = "::Pipedrive::#{class_name}" unless class_name.include?("Pipedrive")
|
76
|
+
define_method(resource_name) do |params = {}|
|
77
|
+
response = request(:get,
|
78
|
+
"#{resource_url}/#{resource_name}",
|
79
|
+
params.merge(options))
|
80
|
+
response.dig(:data)&.map do |data|
|
81
|
+
class_name_as_sym = class_name_lower_case.to_sym
|
82
|
+
if data.key?(class_name_as_sym)
|
83
|
+
data = data.merge(data.delete(class_name_as_sym))
|
84
|
+
end
|
85
|
+
Object.const_get(class_name).new(data)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def initialize(data = {})
|
91
|
+
@data = @unsaved_data = {}
|
92
|
+
initialize_from_data(data)
|
93
|
+
end
|
94
|
+
|
95
|
+
def resource_url
|
96
|
+
"#{self.class.resource_url}/#{id}"
|
97
|
+
end
|
98
|
+
|
99
|
+
def search_for_fields(values)
|
100
|
+
self.class.search_for_fields(values)
|
101
|
+
end
|
102
|
+
|
103
|
+
def update_attributes(new_attrs)
|
104
|
+
new_attrs.delete("id")
|
105
|
+
@data.merge!(new_attrs)
|
106
|
+
end
|
107
|
+
|
108
|
+
def refresh
|
109
|
+
response = request(:get, resource_url)
|
110
|
+
initialize_from_data(response.dig(:data))
|
111
|
+
end
|
112
|
+
|
113
|
+
def initialize_from_data(data)
|
114
|
+
klass = self.class
|
115
|
+
# init data
|
116
|
+
@data = data
|
117
|
+
# generate the methods
|
118
|
+
data.each_key do |k|
|
119
|
+
# it could be a custom field diccionary
|
120
|
+
m, is_custom_field = klass.fields_dicc&.dig(k) &&
|
121
|
+
[klass.fields_dicc&.dig(k), true] ||
|
122
|
+
[k, false]
|
123
|
+
|
124
|
+
if m == :method
|
125
|
+
# Object#method is a built-in Ruby method that accepts a symbol
|
126
|
+
# and returns the corresponding Method object. Because the API may
|
127
|
+
# also use `method` as a field name, we check the arity of *args
|
128
|
+
# to decide whether to act as a getter or call the parent method.
|
129
|
+
klass.define_method(m) do |*args|
|
130
|
+
args.empty? ? fetch_value(m, is_custom_field) : super(*args)
|
131
|
+
end
|
132
|
+
else
|
133
|
+
klass.define_method(m) { fetch_value(m, is_custom_field) }
|
134
|
+
end
|
135
|
+
|
136
|
+
klass.define_method(:"#{m}=") do |value|
|
137
|
+
@data[m] = @unsaved_data[m] = value
|
138
|
+
end
|
139
|
+
|
140
|
+
next unless [FalseClass, TrueClass].include?(
|
141
|
+
fetch_value(m, is_custom_field).class
|
142
|
+
)
|
143
|
+
|
144
|
+
klass.define_method(:"#{m}?") do
|
145
|
+
fetch_value(m, is_custom_field)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
self
|
149
|
+
end
|
150
|
+
|
151
|
+
protected def fetch_value(key, is_custom_field)
|
152
|
+
@data[is_custom_field ? self.class.inverted_fields_dicc.dig(key) : key]
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pipedrive
|
4
|
+
class Deal < Resource
|
5
|
+
include Fields
|
6
|
+
include Merge
|
7
|
+
|
8
|
+
has_many :products, class_name: "Product"
|
9
|
+
|
10
|
+
# POST /deals/:id/products
|
11
|
+
# Add a product to this deal
|
12
|
+
def add_product(product, params)
|
13
|
+
raise "Param *product* is not an instance of Pipedrive::Product" \
|
14
|
+
unless product.is_a?(Pipedrive::Product)
|
15
|
+
raise "Param :item_price is required" unless params.key?(:item_price)
|
16
|
+
raise "Param :quantity is required" unless params.key?(:quantity)
|
17
|
+
|
18
|
+
response = request(
|
19
|
+
:post,
|
20
|
+
"#{resource_url}/products",
|
21
|
+
params.merge(id: id, product_id: product.id)
|
22
|
+
)
|
23
|
+
Product.new(response.dig(:data))
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pipedrive
|
4
|
+
class Organization < Resource
|
5
|
+
include Fields
|
6
|
+
include Merge
|
7
|
+
|
8
|
+
has_many :activities, class_name: "Activity"
|
9
|
+
has_many :deals, class_name: "Deal"
|
10
|
+
has_many :persons, class_name: "Person"
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "pipedrive/resources/activity"
|
4
|
+
require "pipedrive/resources/activity_type"
|
5
|
+
require "pipedrive/resources/call_log"
|
6
|
+
require "pipedrive/resources/currency"
|
7
|
+
require "pipedrive/resources/deal"
|
8
|
+
require "pipedrive/resources/file"
|
9
|
+
require "pipedrive/resources/filter"
|
10
|
+
require "pipedrive/resources/global_message"
|
11
|
+
require "pipedrive/resources/goal"
|
12
|
+
require "pipedrive/resources/lead_label"
|
13
|
+
require "pipedrive/resources/lead_source"
|
14
|
+
require "pipedrive/resources/lead"
|
15
|
+
require "pipedrive/resources/organization"
|
16
|
+
require "pipedrive/resources/pipeline"
|
17
|
+
require "pipedrive/resources/product"
|
18
|
+
require "pipedrive/resources/person"
|
19
|
+
require "pipedrive/resources/note"
|
20
|
+
require "pipedrive/resources/stage"
|
21
|
+
require "pipedrive/resources/team"
|
22
|
+
require "pipedrive/resources/user"
|
23
|
+
require "pipedrive/resources/webhook"
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pipedrive
|
4
|
+
module Util
|
5
|
+
def self.serialize_response(response, symbolize_names: true)
|
6
|
+
rjson = JSON.parse(response.body, symbolize_names: symbolize_names)
|
7
|
+
return rjson if response.success?
|
8
|
+
|
9
|
+
raise_error(response.status, rjson)
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.raise_error(status, response)
|
13
|
+
case status
|
14
|
+
when 404
|
15
|
+
raise NotFoundError.new(response.dig(:error), status)
|
16
|
+
else
|
17
|
+
raise UnkownAPIError.new(response.dig(:error), status)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.debug(message)
|
22
|
+
Pipedrive.logger&.debug(message) if Pipedrive.debug
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/lib/pipedrive.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "logger"
|
4
|
+
require "faraday"
|
5
|
+
require "json"
|
6
|
+
|
7
|
+
# Version
|
8
|
+
require "pipedrive/version"
|
9
|
+
|
10
|
+
# API operations
|
11
|
+
require "pipedrive/api_operations/request"
|
12
|
+
require "pipedrive/api_operations/create"
|
13
|
+
require "pipedrive/api_operations/update"
|
14
|
+
require "pipedrive/api_operations/delete"
|
15
|
+
|
16
|
+
# Support classes
|
17
|
+
require "pipedrive/resource"
|
18
|
+
require "pipedrive/errors"
|
19
|
+
require "pipedrive/util"
|
20
|
+
require "pipedrive/fields"
|
21
|
+
require "pipedrive/merge"
|
22
|
+
|
23
|
+
# Named API Resources
|
24
|
+
require "pipedrive/resources"
|
25
|
+
|
26
|
+
module Pipedrive
|
27
|
+
BASE_URL = "https://api.pipedrive.com/v1"
|
28
|
+
|
29
|
+
class << self
|
30
|
+
attr_accessor :api_key, :logger, :debug, :debug_http, :debug_http_body
|
31
|
+
end
|
32
|
+
|
33
|
+
@logger = Logger.new(STDOUT)
|
34
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
$LOAD_PATH.unshift(::File.join(::File.dirname(__FILE__), "lib"))
|
4
|
+
|
5
|
+
require "pipedrive/version"
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = "pipedrive-connect"
|
9
|
+
spec.version = Pipedrive::VERSION
|
10
|
+
spec.authors = "Get on Board"
|
11
|
+
spec.email = "team@getonbrd.com"
|
12
|
+
|
13
|
+
spec.summary = "Ruby binding for the pipedrive API."
|
14
|
+
spec.homepage = "https://github.com/getonbrd/pipedrive-connect"
|
15
|
+
spec.license = "MIT"
|
16
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
|
17
|
+
|
18
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
19
|
+
spec.metadata["source_code_uri"] = "https://github.com/getonbrd/pipedrive-connect"
|
20
|
+
spec.metadata["changelog_uri"] = "https://github.com/getonbrd/pipedrive-connect/CHANGELOG.md"
|
21
|
+
|
22
|
+
# Specify which files should be added to the gem when it is released.
|
23
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
25
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
26
|
+
end
|
27
|
+
spec.bindir = "exe"
|
28
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
29
|
+
spec.require_paths = ["lib"]
|
30
|
+
|
31
|
+
# dependencies
|
32
|
+
spec.add_dependency("faraday", "~> 1.3")
|
33
|
+
end
|
metadata
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pipedrive-connect
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.2.4
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Get on Board
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-02-24 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: faraday
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.3'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.3'
|
27
|
+
description:
|
28
|
+
email: team@getonbrd.com
|
29
|
+
executables: []
|
30
|
+
extensions: []
|
31
|
+
extra_rdoc_files: []
|
32
|
+
files:
|
33
|
+
- ".gitignore"
|
34
|
+
- ".rspec"
|
35
|
+
- ".rubocop.yml"
|
36
|
+
- ".vscode/settings.json"
|
37
|
+
- CHANGELOG.md
|
38
|
+
- CODE_OF_CONDUCT.md
|
39
|
+
- Gemfile
|
40
|
+
- LICENSE
|
41
|
+
- README.md
|
42
|
+
- Rakefile
|
43
|
+
- bin/console
|
44
|
+
- bin/setup
|
45
|
+
- lib/pipedrive.rb
|
46
|
+
- lib/pipedrive/api_operations/create.rb
|
47
|
+
- lib/pipedrive/api_operations/delete.rb
|
48
|
+
- lib/pipedrive/api_operations/request.rb
|
49
|
+
- lib/pipedrive/api_operations/update.rb
|
50
|
+
- lib/pipedrive/errors.rb
|
51
|
+
- lib/pipedrive/fields.rb
|
52
|
+
- lib/pipedrive/merge.rb
|
53
|
+
- lib/pipedrive/resource.rb
|
54
|
+
- lib/pipedrive/resources.rb
|
55
|
+
- lib/pipedrive/resources/activity.rb
|
56
|
+
- lib/pipedrive/resources/activity_type.rb
|
57
|
+
- lib/pipedrive/resources/call_log.rb
|
58
|
+
- lib/pipedrive/resources/currency.rb
|
59
|
+
- lib/pipedrive/resources/deal.rb
|
60
|
+
- lib/pipedrive/resources/file.rb
|
61
|
+
- lib/pipedrive/resources/filter.rb
|
62
|
+
- lib/pipedrive/resources/global_message.rb
|
63
|
+
- lib/pipedrive/resources/goal.rb
|
64
|
+
- lib/pipedrive/resources/lead.rb
|
65
|
+
- lib/pipedrive/resources/lead_label.rb
|
66
|
+
- lib/pipedrive/resources/lead_source.rb
|
67
|
+
- lib/pipedrive/resources/note.rb
|
68
|
+
- lib/pipedrive/resources/organization.rb
|
69
|
+
- lib/pipedrive/resources/person.rb
|
70
|
+
- lib/pipedrive/resources/pipeline.rb
|
71
|
+
- lib/pipedrive/resources/product.rb
|
72
|
+
- lib/pipedrive/resources/stage.rb
|
73
|
+
- lib/pipedrive/resources/team.rb
|
74
|
+
- lib/pipedrive/resources/user.rb
|
75
|
+
- lib/pipedrive/resources/webhook.rb
|
76
|
+
- lib/pipedrive/util.rb
|
77
|
+
- lib/pipedrive/version.rb
|
78
|
+
- pipedrive-connect.gemspec
|
79
|
+
homepage: https://github.com/getonbrd/pipedrive-connect
|
80
|
+
licenses:
|
81
|
+
- MIT
|
82
|
+
metadata:
|
83
|
+
homepage_uri: https://github.com/getonbrd/pipedrive-connect
|
84
|
+
source_code_uri: https://github.com/getonbrd/pipedrive-connect
|
85
|
+
changelog_uri: https://github.com/getonbrd/pipedrive-connect/CHANGELOG.md
|
86
|
+
post_install_message:
|
87
|
+
rdoc_options: []
|
88
|
+
require_paths:
|
89
|
+
- lib
|
90
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
91
|
+
requirements:
|
92
|
+
- - ">="
|
93
|
+
- !ruby/object:Gem::Version
|
94
|
+
version: 2.3.0
|
95
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
96
|
+
requirements:
|
97
|
+
- - ">="
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: '0'
|
100
|
+
requirements: []
|
101
|
+
rubygems_version: 3.0.6
|
102
|
+
signing_key:
|
103
|
+
specification_version: 4
|
104
|
+
summary: Ruby binding for the pipedrive API.
|
105
|
+
test_files: []
|