opendoor-partner-sdk-server-ruby 1.1.6.beta.89.1
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/LICENSE.txt +118 -0
- data/README.md +78 -0
- data/lib/opendoor/partner_sdk/answer_key_mapping.rb +187 -0
- data/lib/opendoor/partner_sdk/client.rb +412 -0
- data/lib/opendoor/partner_sdk/errors.rb +24 -0
- data/lib/opendoor/partner_sdk/graphql/mutations.rb +37 -0
- data/lib/opendoor/partner_sdk/graphql/queries.rb +120 -0
- data/lib/opendoor/partner_sdk/validators.rb +55 -0
- data/lib/opendoor/partner_sdk/version.rb +5 -0
- data/lib/opendoor/partner_sdk.rb +12 -0
- metadata +84 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d37317c01e3cd9592d709cb3237fed1684aa0bcd5d878257b1ccefbf0b21e675
|
|
4
|
+
data.tar.gz: '0728067630fb3ce7a0d70f6231df246e689c46e45df9396c4a4e896dbbf07272'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8f553886988ebe99b974876a38e98dbf74bb0299386350fe8b42de3ebf888b16599f3be46f467b4b5953b7b5ea55c16a65395257e11ec38d46060d7d9364aee8
|
|
7
|
+
data.tar.gz: a7c722ac79a461f0177331b6864c6fc098b5567e87ae45488da4431ffa000fbd039eee6ed4e41366a3aac2cd69f3cebf8eea7d3b7c607c4bda798c3e8af38ae3
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
PROPRIETARY LICENSE — OPENDOOR PARTNER SDK (DISTRIBUTED ARTIFACTS ONLY)
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 Opendoor Labs. All rights reserved.
|
|
4
|
+
|
|
5
|
+
1. Scope
|
|
6
|
+
|
|
7
|
+
This license applies solely to the bundled, compiled, or otherwise distributable
|
|
8
|
+
artifacts made available by Opendoor Labs on public package registries as part
|
|
9
|
+
of the "Opendoor Partner SDK" (the "Artifacts"). "Artifacts" include, without
|
|
10
|
+
limitation:
|
|
11
|
+
|
|
12
|
+
- The Ruby gem "opendoor-partner-sdk-server-ruby" distributed via RubyGems.org;
|
|
13
|
+
and
|
|
14
|
+
- The JavaScript/TypeScript npm packages published under the @opendoor/ scope
|
|
15
|
+
that are built from this repository (including, for example,
|
|
16
|
+
@opendoor/partner-sdk-server-js-core, @opendoor/partner-sdk-client-js-core,
|
|
17
|
+
@opendoor/partner-sdk-client-react, and @opendoor/partner-sdk-client-vue).
|
|
18
|
+
|
|
19
|
+
For clarity, this license does not grant any rights to any source code, build
|
|
20
|
+
scripts, or other materials that are not included in the Artifacts. All such
|
|
21
|
+
materials remain proprietary and confidential to Opendoor Labs.
|
|
22
|
+
|
|
23
|
+
2. Prerequisite; Partner Agreement Controls
|
|
24
|
+
|
|
25
|
+
No license is granted under this notice unless you (or the entity on whose
|
|
26
|
+
behalf you act) have an active, fully executed written partner agreement with
|
|
27
|
+
Opendoor Labs Inc. ("Opendoor") governing your use of Opendoor's partner APIs
|
|
28
|
+
(the "Partner Agreement") and you are in compliance with the Partner Agreement.
|
|
29
|
+
Use of the Artifacts without an active Partner Agreement is not authorized.
|
|
30
|
+
|
|
31
|
+
If there is a conflict between this license and the Partner Agreement, the
|
|
32
|
+
Partner Agreement will control.
|
|
33
|
+
|
|
34
|
+
3. Limited License Grant
|
|
35
|
+
|
|
36
|
+
Subject to Section 2, Opendoor grants you a limited, non-exclusive,
|
|
37
|
+
non-transferable, non-sublicensable, revocable license to: (a) download,
|
|
38
|
+
install, copy, and use the Artifacts for internal development, testing, and
|
|
39
|
+
operation; and (b) use the Artifacts solely to integrate your applications with
|
|
40
|
+
Opendoor partner APIs in accordance with Opendoor's published documentation and
|
|
41
|
+
the Partner Agreement.
|
|
42
|
+
|
|
43
|
+
You may reproduce the Artifacts as reasonably necessary for backup, caching,
|
|
44
|
+
CI/CD, and deployment, and you may distribute the Artifacts only as embedded or
|
|
45
|
+
included with your application for the authorized purpose above, and not on a
|
|
46
|
+
standalone basis.
|
|
47
|
+
|
|
48
|
+
4. Restrictions
|
|
49
|
+
|
|
50
|
+
Except as expressly permitted in Section 3 or the Partner Agreement, you may
|
|
51
|
+
not (and may not permit any third party to): (a) copy, modify, translate, or
|
|
52
|
+
create derivative works of the Artifacts; (b) distribute, sublicense, sell,
|
|
53
|
+
lease, or otherwise make the Artifacts available to any third party on a
|
|
54
|
+
standalone basis; (c) reverse engineer, decompile, or disassemble the
|
|
55
|
+
Artifacts, except to the extent such restriction is prohibited by applicable
|
|
56
|
+
law; (d) remove, obscure, or alter any proprietary notices in the Artifacts; or
|
|
57
|
+
(e) use the Artifacts for any purpose other than the authorized integration with
|
|
58
|
+
Opendoor partner APIs. No rights are granted to Opendoor Labs' trademarks,
|
|
59
|
+
service marks, or logos under this license.
|
|
60
|
+
|
|
61
|
+
5. Ownership
|
|
62
|
+
|
|
63
|
+
The Artifacts are licensed, not sold. Opendoor retains all right, title, and
|
|
64
|
+
interest in and to the Artifacts, including all intellectual property rights
|
|
65
|
+
therein.
|
|
66
|
+
|
|
67
|
+
6. Disclaimer
|
|
68
|
+
|
|
69
|
+
THE ARTIFACTS ARE PROVIDED "AS IS" AND "AS AVAILABLE," WITHOUT WARRANTY OF ANY
|
|
70
|
+
KIND, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE, INCLUDING ANY IMPLIED
|
|
71
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND
|
|
72
|
+
NON-INFRINGEMENT.
|
|
73
|
+
|
|
74
|
+
7. Limitation of Liability
|
|
75
|
+
|
|
76
|
+
TO THE MAXIMUM EXTENT PERMITTED BY LAW, IN NO EVENT WILL OPENDOOR BE LIABLE FOR
|
|
77
|
+
ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, PUNITIVE, OR EXEMPLARY
|
|
78
|
+
DAMAGES, OR FOR ANY LOSS OF PROFITS, REVENUE, DATA, OR BUSINESS INTERRUPTION,
|
|
79
|
+
ARISING OUT OF OR RELATED TO THE ARTIFACTS OR THIS LICENSE, EVEN IF ADVISED OF
|
|
80
|
+
THE POSSIBILITY OF SUCH DAMAGES.
|
|
81
|
+
|
|
82
|
+
TO THE MAXIMUM EXTENT PERMITTED BY LAW, OPENDOOR'S TOTAL LIABILITY ARISING OUT
|
|
83
|
+
OF OR RELATED TO THE ARTIFACTS OR THIS LICENSE WILL NOT EXCEED THE LESSER OF
|
|
84
|
+
$100 OR THE AMOUNTS PAID BY YOU TO OPENDOOR UNDER THE PARTNER AGREEMENT IN THE
|
|
85
|
+
TWELVE (12) MONTHS PRECEDING THE EVENT GIVING RISE TO THE CLAIM.
|
|
86
|
+
|
|
87
|
+
8. Termination
|
|
88
|
+
|
|
89
|
+
This license will terminate automatically upon: (a) any breach of this license;
|
|
90
|
+
(b) expiration or termination of the Partner Agreement; or (c) Opendoor's
|
|
91
|
+
written notice. Upon termination, you must immediately cease all use of the
|
|
92
|
+
Artifacts and destroy all copies in your possession or control, except that you
|
|
93
|
+
may retain copies solely as required to comply with applicable law or bona fide
|
|
94
|
+
internal record retention policies.
|
|
95
|
+
|
|
96
|
+
9. Export and Sanctions Compliance
|
|
97
|
+
|
|
98
|
+
You represent and warrant that you are not (and are not acting on behalf of)
|
|
99
|
+
any person or entity that is the subject or target of sanctions administered or
|
|
100
|
+
enforced by the United States (including the U.S. Department of the Treasury's
|
|
101
|
+
Office of Foreign Assets Control), and that you will not use, export,
|
|
102
|
+
re-export, transfer, or release the Artifacts in violation of applicable export
|
|
103
|
+
control or sanctions laws and regulations.
|
|
104
|
+
|
|
105
|
+
10. Governing Law; Venue
|
|
106
|
+
|
|
107
|
+
This license is governed by and construed in accordance with the laws of the
|
|
108
|
+
State of California, without regard to its conflict of law principles. Subject
|
|
109
|
+
to any dispute resolution provisions in the Partner Agreement, any dispute
|
|
110
|
+
arising out of or relating to the Artifacts or this license will be brought
|
|
111
|
+
exclusively in the state or federal courts located in California, and each
|
|
112
|
+
party consents to personal jurisdiction and venue there.
|
|
113
|
+
|
|
114
|
+
11. Severability
|
|
115
|
+
|
|
116
|
+
If any provision of this license is held to be invalid or unenforceable, that
|
|
117
|
+
provision will be enforced to the maximum extent permissible, and the remaining
|
|
118
|
+
provisions will remain in full force and effect.
|
data/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Opendoor Partner Server Ruby SDK
|
|
2
|
+
|
|
3
|
+
Standalone Ruby port of `packages/server-js-core`.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "opendoor-partner-sdk-server-ruby"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
require "opendoor/partner_sdk"
|
|
15
|
+
|
|
16
|
+
client = Opendoor::PartnerSdk::Client.new(
|
|
17
|
+
api_key: ENV.fetch("OPENDOOR_API_KEY"),
|
|
18
|
+
environment: :staging
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
offer = client.create_offer(
|
|
22
|
+
data: {
|
|
23
|
+
address: {
|
|
24
|
+
street1: "123 Main St",
|
|
25
|
+
city: "Austin",
|
|
26
|
+
state: "TX",
|
|
27
|
+
postalCode: "78701"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The `timeout:` keyword accepts **milliseconds** (default `30_000`), matching `@opendoor/partner-sdk-server-js-core`.
|
|
34
|
+
|
|
35
|
+
## API Methods
|
|
36
|
+
|
|
37
|
+
- `get_address_suggestions(query:)`
|
|
38
|
+
- `create_offer(data:)`
|
|
39
|
+
- `update_offer(offer_id:, data:)`
|
|
40
|
+
- `get_offer(offer_id:)`
|
|
41
|
+
- `get_offer_with_sell_direct_pricing(offer_id:)`
|
|
42
|
+
- `check_address_unit(address:)`
|
|
43
|
+
- `get_address_details(address:, tracking_id: nil)`
|
|
44
|
+
- `get_homebuilders`
|
|
45
|
+
- `get_assessment_slots(offer_id:)`
|
|
46
|
+
- `get_home_detail(opendoor_offer_request_id:)`
|
|
47
|
+
|
|
48
|
+
## Errors
|
|
49
|
+
|
|
50
|
+
- `Opendoor::PartnerSdk::ValidationError` for local input validation
|
|
51
|
+
- `Opendoor::PartnerSdk::ApiError` for network/API failures, GraphQL errors, and invalid JSON (`code: "PARSE_ERROR"`)
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
The Gem is **proprietary** to Opendoor Labs. Opendoor distributes it only as the **bundled package** on RubyGems.org under the terms in [`LICENSE.txt`](LICENSE.txt). Development sources and the broader codebase are **not** offered under an open-source license. Use is limited to integrating with Opendoor partner APIs as described in Opendoor documentation.
|
|
56
|
+
|
|
57
|
+
## Publishing (maintainers)
|
|
58
|
+
|
|
59
|
+
CI builds and tests this gem on every PR (`ci.yml`). Releases mirror the JS packages:
|
|
60
|
+
|
|
61
|
+
| Trigger | Workflow | What gets published |
|
|
62
|
+
|--------|----------|----------------------|
|
|
63
|
+
| GitHub **Release** published (semver tag, e.g. `v1.2.3`) | [`publish.yml`](../../.github/workflows/publish.yml) — job `publish-rubygem` | Release gem `opendoor-partner-sdk-server-ruby` at that version |
|
|
64
|
+
| Push to **`main`** | [`publish-next.yml`](../../.github/workflows/publish-next.yml) — job `publish-rubygem-next` | Prerelease gem `X.Y.Z.beta.{run}.{attempt}` |
|
|
65
|
+
|
|
66
|
+
### GitHub Actions secrets
|
|
67
|
+
|
|
68
|
+
| Secret | Required for | Where to get it |
|
|
69
|
+
|--------|----------------|-----------------|
|
|
70
|
+
| **`RUBYGEMS_API_KEY`** | `gem push` to rubygems.org | [rubygems.org → Edit profile → API access](https://rubygems.org/profile/edit). Create a key with permission to **push** gems for this gem name. |
|
|
71
|
+
|
|
72
|
+
Add the secret in the repo: **Settings → Secrets and variables → Actions → New repository secret**. Name must be exactly `RUBYGEMS_API_KEY`.
|
|
73
|
+
|
|
74
|
+
The npm workflows continue to use **`NPM_TOKEN`** for JS packages only; Ruby publishing does not use it.
|
|
75
|
+
|
|
76
|
+
### First-time gem name
|
|
77
|
+
|
|
78
|
+
The owning RubyGems account must be authorized to publish `opendoor-partner-sdk-server-ruby` (create the gem once or accept ownership transfer per RubyGems policy).
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
module Opendoor
|
|
2
|
+
module PartnerSdk
|
|
3
|
+
module AnswerKeyMapping
|
|
4
|
+
DOT_TO_CAMEL = {
|
|
5
|
+
"home.bedrooms" => "homeBedrooms",
|
|
6
|
+
"home.bathrooms.full" => "homeBathroomsFull",
|
|
7
|
+
"home.bathrooms.half" => "homeBathroomsPartial",
|
|
8
|
+
"home.above_grade_sq_ft" => "homeAboveGradeSqFt",
|
|
9
|
+
"home.dwelling_type" => "homeDwellingType",
|
|
10
|
+
"home.year_built" => "homeYearBuilt",
|
|
11
|
+
"home.exterior_stories" => "homeExteriorStories",
|
|
12
|
+
"home.entry_type" => "homeEntryType",
|
|
13
|
+
"home.covered_parking_type" => "homeCoveredParkingType",
|
|
14
|
+
"home.garage_spaces" => "homeGarageSpaces",
|
|
15
|
+
"home.carport_spaces" => "homeCarportSpaces",
|
|
16
|
+
"home.pool_type" => "homePoolType",
|
|
17
|
+
"home.has_basement" => "homeHasBasement",
|
|
18
|
+
"home.basement_finished_sq_ft" => "homeBasementFinishedSqFt",
|
|
19
|
+
"home.basement_unfinished_sq_ft" => "homeBasementUnfinishedSqFt",
|
|
20
|
+
"home.kitchen_counter_type" => "homeKitchenCounterType",
|
|
21
|
+
"home.hoa" => "homeHoa",
|
|
22
|
+
"home.hoa_fees" => "homeHoaFees",
|
|
23
|
+
"home.has_upgrades" => "homeHasUpgrades",
|
|
24
|
+
"home.kitchen_seller_score" => "homeKitchenCondition",
|
|
25
|
+
"home.bathroom_seller_score" => "homeBathroomCondition",
|
|
26
|
+
"home.living_room_seller_score" => "homeLivingRoomCondition",
|
|
27
|
+
"home.exterior_seller_score" => "homeExteriorCondition",
|
|
28
|
+
"home.eligibility_criteria.leased_solar_panels" => "homeEligibilityCriteriaLeasedSolarPanels",
|
|
29
|
+
"home.eligibility_criteria.known_foundation_issues" => "homeEligibilityCriteriaKnownFoundationIssues",
|
|
30
|
+
"home.eligibility_criteria.fire_damage" => "homeEligibilityCriteriaFireDamage",
|
|
31
|
+
"home.eligibility_criteria.well_water" => "homeEligibilityCriteriaWellWater",
|
|
32
|
+
"home.eligibility_criteria.septic" => "homeEligibilityCriteriaSeptic",
|
|
33
|
+
"home.eligibility_criteria.asbestos_siding" => "homeEligibilityCriteriaAsbestosSiding",
|
|
34
|
+
"home.eligibility_criteria.livestock" => "homeEligibilityCriteriaLivestock",
|
|
35
|
+
"home.eligibility_criteria.mobile_manufactured_home" => "homeEligibilityCriteriaMobileManufacturedHome",
|
|
36
|
+
"seller.full_name" => "sellerFullName",
|
|
37
|
+
"seller.email" => "sellerEmail",
|
|
38
|
+
"seller.phone_number" => "sellerPhoneNumber",
|
|
39
|
+
"seller.sms_opt_in" => "sellerSmsOptIn",
|
|
40
|
+
"seller.relation_to_owner" => "sellerRelationToOwner",
|
|
41
|
+
"seller.relation_to_owner.other" => "sellerRelationToOwnerOther",
|
|
42
|
+
"seller.sale_timeline" => "sellerSaleTimeline",
|
|
43
|
+
"seller.working_with_home_builder" => "sellerWorkingWithHomeBuilder",
|
|
44
|
+
"seller.home_builder" => "sellerHomeBuilder",
|
|
45
|
+
"seller.home_builder_email" => "sellerHomeBuilderEmail",
|
|
46
|
+
"seller.home_builder_community" => "sellerHomeBuilderCommunity"
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
SKIP_KEYS = ["offerId"].freeze
|
|
50
|
+
|
|
51
|
+
NUMERIC_KEYS = [
|
|
52
|
+
"home.bedrooms",
|
|
53
|
+
"home.bathrooms.full",
|
|
54
|
+
"home.bathrooms.half",
|
|
55
|
+
"home.above_grade_sq_ft",
|
|
56
|
+
"home.exterior_stories",
|
|
57
|
+
"home.year_built",
|
|
58
|
+
"home.garage_spaces",
|
|
59
|
+
"home.carport_spaces",
|
|
60
|
+
"home.hoa_fees",
|
|
61
|
+
"home.basement_finished_sq_ft",
|
|
62
|
+
"home.basement_unfinished_sq_ft"
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
65
|
+
EXPERIMENTAL_KEYS = [
|
|
66
|
+
"home.mortgage_balance",
|
|
67
|
+
"home.lot_sq_ft",
|
|
68
|
+
"home.upper_level_sq_ft",
|
|
69
|
+
"home.basement_sq_ft"
|
|
70
|
+
].freeze
|
|
71
|
+
|
|
72
|
+
POOL_TYPE_NORMALIZE = {
|
|
73
|
+
"none" => "no_pool",
|
|
74
|
+
"community" => "community_pool"
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
77
|
+
module_function
|
|
78
|
+
|
|
79
|
+
def transform_answers_to_offer_input(answers)
|
|
80
|
+
offer_input = {}
|
|
81
|
+
experimental = []
|
|
82
|
+
|
|
83
|
+
answers.each do |raw_key, raw_value|
|
|
84
|
+
key = raw_key.to_s
|
|
85
|
+
next if raw_value.nil? || SKIP_KEYS.include?(key)
|
|
86
|
+
|
|
87
|
+
value = normalize_pool_type(key, raw_value)
|
|
88
|
+
|
|
89
|
+
if key == "home.hoa_type"
|
|
90
|
+
values = value.is_a?(Array) ? value : [value]
|
|
91
|
+
next if values.length == 1 && values.first == "none"
|
|
92
|
+
|
|
93
|
+
offer_input["homeHoaTypeAgeRestrictedCommunity"] = values.include?("age_restricted_community")
|
|
94
|
+
offer_input["homeHoaTypeGatedCommunity"] = values.include?("gated_community")
|
|
95
|
+
next
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if key == "home.eligibility_criteria"
|
|
99
|
+
map_eligibility_criteria(offer_input, value)
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if key == "home.hoa_type.guarded_gated_community"
|
|
104
|
+
offer_input["homeHoaTypeGuardedGatedCommunity"] = value == "has_guard"
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
camel_key = DOT_TO_CAMEL[key]
|
|
109
|
+
if camel_key
|
|
110
|
+
offer_input[camel_key] = coerce_mapped_value(key, value)
|
|
111
|
+
elsif experimental_key?(key)
|
|
112
|
+
experimental_value = to_experimental_entry(key, value)
|
|
113
|
+
experimental << experimental_value if experimental_value
|
|
114
|
+
else
|
|
115
|
+
offer_input[key] = value
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
offer_input["experimental"] = experimental unless experimental.empty?
|
|
120
|
+
offer_input
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def normalize_pool_type(key, value)
|
|
124
|
+
return value unless key == "home.pool_type"
|
|
125
|
+
return value unless value.is_a?(String)
|
|
126
|
+
|
|
127
|
+
POOL_TYPE_NORMALIZE.fetch(value, value)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def map_eligibility_criteria(offer_input, value)
|
|
131
|
+
values = value.is_a?(Array) ? value : []
|
|
132
|
+
criteria_map = {
|
|
133
|
+
"leased_solar_panels" => "homeEligibilityCriteriaLeasedSolarPanels",
|
|
134
|
+
"known_foundation_issues" => "homeEligibilityCriteriaKnownFoundationIssues",
|
|
135
|
+
"fire_damage" => "homeEligibilityCriteriaFireDamage",
|
|
136
|
+
"well_water" => "homeEligibilityCriteriaWellWater",
|
|
137
|
+
"septic" => "homeEligibilityCriteriaSeptic",
|
|
138
|
+
"asbestos_siding" => "homeEligibilityCriteriaAsbestosSiding",
|
|
139
|
+
"livestock" => "homeEligibilityCriteriaLivestock",
|
|
140
|
+
"mobile_manufactured_home" => "homeEligibilityCriteriaMobileManufacturedHome",
|
|
141
|
+
"unique_ownership_structure" => "homeEligibilityCriteriaUniqueOwnershipStructure",
|
|
142
|
+
"below_market_rate_ownership" => "homeEligibilityCriteriaBelowMarketRateOwnership",
|
|
143
|
+
"rent_controlled_tenant_occupied" => "homeEligibilityCriteriaRentControlledTenantOccupied"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
criteria_map.each do |criteria_value, graphql_field|
|
|
147
|
+
offer_input[graphql_field] = values.include?(criteria_value)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def coerce_mapped_value(key, value)
|
|
152
|
+
return true if value == "yes"
|
|
153
|
+
return false if value == "no"
|
|
154
|
+
return true if value == "true"
|
|
155
|
+
return false if value == "false"
|
|
156
|
+
|
|
157
|
+
if value.is_a?(String) && !value.empty? && NUMERIC_KEYS.include?(key) && numeric_string?(value)
|
|
158
|
+
return value.include?(".") ? value.to_f : value.to_i
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
value
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def numeric_string?(value)
|
|
165
|
+
!!Float(value)
|
|
166
|
+
rescue ArgumentError, TypeError
|
|
167
|
+
false
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def experimental_key?(key)
|
|
171
|
+
key.start_with?("home.condition.") ||
|
|
172
|
+
key.start_with?("home.features.") ||
|
|
173
|
+
EXPERIMENTAL_KEYS.include?(key)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def to_experimental_entry(key, value)
|
|
177
|
+
if value.is_a?(Array)
|
|
178
|
+
{ "questionKey" => key, "stringValues" => value }
|
|
179
|
+
elsif value.is_a?(Numeric)
|
|
180
|
+
{ "questionKey" => key, "doubleValue" => value }
|
|
181
|
+
elsif value == true || value == false
|
|
182
|
+
{ "questionKey" => key, "stringValues" => [value ? "true" : "false"] }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "set"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Opendoor
|
|
8
|
+
module PartnerSdk
|
|
9
|
+
class Client
|
|
10
|
+
VALID_OFFER_INPUT_FIELDS = %w[
|
|
11
|
+
sellerEmail sellerFullName sellerPhoneNumber sellerRelationToOwner sellerWorkingWithHomeBuilder
|
|
12
|
+
sellerSaleTimeline homeDwellingType homeBedrooms homeBathroomsFull homeBathroomsPartial homeYearBuilt
|
|
13
|
+
homeExteriorStories homeAboveGradeSqFt homeCoveredParkingType homeGarageSpaces homeCarportSpaces
|
|
14
|
+
homePoolType homeHasBasement homeBasementFinishedSqFt homeBasementUnfinishedSqFt homeKitchenCounterType
|
|
15
|
+
homeHoa homeHoaTypeAgeRestrictedCommunity homeHoaTypeGatedCommunity homeHoaTypeGuardedGatedCommunity
|
|
16
|
+
homeEntryType homeKitchenCondition homeBathroomCondition homeLivingRoomCondition homeExteriorCondition
|
|
17
|
+
homeEligibilityCriteriaLeasedSolarPanels homeEligibilityCriteriaKnownFoundationIssues homeEligibilityCriteriaFireDamage
|
|
18
|
+
homeEligibilityCriteriaWellWater homeEligibilityCriteriaSeptic homeEligibilityCriteriaAsbestosSiding
|
|
19
|
+
homeEligibilityCriteriaLivestock homeEligibilityCriteriaMobileManufacturedHome homeEligibilityCriteriaUniqueOwnershipStructure
|
|
20
|
+
homeEligibilityCriteriaBelowMarketRateOwnership homeEligibilityCriteriaRentControlledTenantOccupied homeHoaFees
|
|
21
|
+
homeHasUpgrades sellerSmsOptIn correlationId experimental
|
|
22
|
+
].to_set.freeze
|
|
23
|
+
|
|
24
|
+
ENUM_FIELDS = %w[
|
|
25
|
+
homeDwellingType homeCoveredParkingType homePoolType homeEntryType homeKitchenCounterType
|
|
26
|
+
homeKitchenCondition homeBathroomCondition homeLivingRoomCondition homeExteriorCondition sellerRelationToOwner
|
|
27
|
+
].to_set.freeze
|
|
28
|
+
|
|
29
|
+
ENDPOINTS = {
|
|
30
|
+
production: {
|
|
31
|
+
api_endpoint: "https://partner.opendoor.com/api/graphql",
|
|
32
|
+
address_endpoint: "https://www.opendoor.com",
|
|
33
|
+
gateway_endpoint: "https://graphql.opendoor.com/api/graphql"
|
|
34
|
+
},
|
|
35
|
+
staging: {
|
|
36
|
+
api_endpoint: "https://partner.simplersell.com/api/graphql",
|
|
37
|
+
address_endpoint: "https://demo.simplersell.com",
|
|
38
|
+
gateway_endpoint: "https://graphql.simplersell.com/api/graphql"
|
|
39
|
+
}
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Matches server-js-core: request timeout in milliseconds (see OpendoorClientConfig.timeout).
|
|
43
|
+
DEFAULT_TIMEOUT_MS = 30_000
|
|
44
|
+
|
|
45
|
+
def initialize(api_key:, environment: :staging, timeout: DEFAULT_TIMEOUT_MS, debug: false, api_endpoint: nil, address_endpoint: nil, gateway_endpoint: nil)
|
|
46
|
+
raise ValidationError.new("apiKey is required", "apiKey") if blank?(api_key)
|
|
47
|
+
|
|
48
|
+
env = environment.to_sym
|
|
49
|
+
endpoints = ENDPOINTS.fetch(env)
|
|
50
|
+
|
|
51
|
+
@api_key = api_key
|
|
52
|
+
@api_endpoint = api_endpoint || endpoints[:api_endpoint]
|
|
53
|
+
@address_endpoint = address_endpoint || endpoints[:address_endpoint]
|
|
54
|
+
@gateway_endpoint = gateway_endpoint || endpoints[:gateway_endpoint]
|
|
55
|
+
@timeout_ms = timeout
|
|
56
|
+
# Net::HTTP expects seconds (fractional allowed); keep parity with JS ms default.
|
|
57
|
+
@timeout_seconds = [@timeout_ms / 1000.0, 0.001].max
|
|
58
|
+
@debug = debug
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def get_address_suggestions(query:)
|
|
62
|
+
return { addresses: [] } if blank?(query)
|
|
63
|
+
|
|
64
|
+
session_token = SecureRandom.uuid
|
|
65
|
+
params = URI.encode_www_form(query: query, sessionToken: session_token)
|
|
66
|
+
url = "#{@address_endpoint}/_address-input/autocomplete?#{params}"
|
|
67
|
+
log("REST GET -> #{@address_endpoint}/_address-input/autocomplete")
|
|
68
|
+
|
|
69
|
+
response = fetch_with_timeout(url, method: :get, headers: { "Content-Type" => "application/json" })
|
|
70
|
+
ensure_ok!(response, "Autocomplete request failed", "NETWORK_ERROR")
|
|
71
|
+
|
|
72
|
+
raw = parse_json_response(response.body, http_status: response.code.to_i)
|
|
73
|
+
predictions = raw.fetch("predictions", [])
|
|
74
|
+
addresses = predictions.map do |prediction|
|
|
75
|
+
begin
|
|
76
|
+
resolve_prediction(prediction, session_token)
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end.compact
|
|
81
|
+
|
|
82
|
+
{ addresses: addresses }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def create_offer(data:)
|
|
86
|
+
Validators.validate_create_offer_request(data)
|
|
87
|
+
address = get(data, :address)
|
|
88
|
+
|
|
89
|
+
result = graphql(
|
|
90
|
+
query: Graphql::OFFER_REQUEST_CREATE,
|
|
91
|
+
variables: {
|
|
92
|
+
offerRequestCreateInput: {
|
|
93
|
+
address: normalize_address(address),
|
|
94
|
+
saleAssociateEmail: get(data, :sales_associate_email, :salesAssociateEmail),
|
|
95
|
+
saleAssociateName: get(data, :sales_associate_name, :salesAssociateName)
|
|
96
|
+
}.compact
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
symbolize_keys(result.fetch("offerRequestCreate").fetch("offerRequest"))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def update_offer(offer_id:, data:)
|
|
104
|
+
Validators.validate_update_offer_request(offer_id, data)
|
|
105
|
+
transformed = AnswerKeyMapping.transform_answers_to_offer_input(stringify_keys(data))
|
|
106
|
+
offer_input = {}
|
|
107
|
+
|
|
108
|
+
transformed.each do |key, value|
|
|
109
|
+
next if value.nil?
|
|
110
|
+
next unless VALID_OFFER_INPUT_FIELDS.include?(key)
|
|
111
|
+
|
|
112
|
+
offer_input[key] = if value.is_a?(String) && ENUM_FIELDS.include?(key)
|
|
113
|
+
value.upcase
|
|
114
|
+
else
|
|
115
|
+
value
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
result = graphql(
|
|
120
|
+
query: Graphql::OFFER_REQUEST_UPDATE,
|
|
121
|
+
variables: {
|
|
122
|
+
offerRequestUpdateInput: {
|
|
123
|
+
opendoorOfferRequestId: offer_id,
|
|
124
|
+
offerInput: offer_input
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
symbolize_keys(result.fetch("offerRequestUpdate").fetch("offerRequest"))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def get_offer(offer_id:)
|
|
133
|
+
raise ValidationError.new("offerId is required", "offerId") if blank?(offer_id)
|
|
134
|
+
|
|
135
|
+
result = graphql(
|
|
136
|
+
query: Graphql::OFFER_REQUEST_QUERY,
|
|
137
|
+
variables: { opendoorOfferRequestId: offer_id }
|
|
138
|
+
)
|
|
139
|
+
symbolize_keys(result.fetch("offerRequest"))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def get_offer_with_sell_direct_pricing(offer_id:)
|
|
143
|
+
raise ValidationError.new("offerId is required", "offerId") if blank?(offer_id)
|
|
144
|
+
|
|
145
|
+
result = graphql(
|
|
146
|
+
query: Graphql::OFFER_REQUEST_WITH_SELL_DIRECT_PRICING,
|
|
147
|
+
variables: { opendoorOfferRequestId: offer_id }
|
|
148
|
+
)
|
|
149
|
+
symbolize_keys(result.fetch("offerRequestWithSellDirectPricing"))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def check_address_unit(address:)
|
|
153
|
+
raise ValidationError.new("street1 is required", "street1") if blank?(get(address, :street1))
|
|
154
|
+
|
|
155
|
+
result = graphql(
|
|
156
|
+
query: Graphql::ADDRESS_UNIT_CHECK,
|
|
157
|
+
variables: { address: normalize_address(address) }
|
|
158
|
+
)
|
|
159
|
+
symbolize_keys(result.fetch("addressUnitCheck"))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def get_address_details(address:, tracking_id: nil)
|
|
163
|
+
raise ValidationError.new("street1 is required", "street1") if blank?(get(address, :street1))
|
|
164
|
+
|
|
165
|
+
variables = { address: normalize_address(address) }
|
|
166
|
+
variables[:trackingId] = tracking_id if tracking_id
|
|
167
|
+
|
|
168
|
+
result = graphql(query: Graphql::ADDRESS_DETAILS_QUERY, variables: variables)
|
|
169
|
+
symbolize_keys(result.fetch("address"))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def get_homebuilders
|
|
173
|
+
log("GraphQL (gateway) -> #{@gateway_endpoint}")
|
|
174
|
+
response = fetch_with_timeout(
|
|
175
|
+
@gateway_endpoint,
|
|
176
|
+
method: :post,
|
|
177
|
+
headers: {
|
|
178
|
+
"Content-Type" => "application/json",
|
|
179
|
+
"apollographql-client-name" => "partner-sdk",
|
|
180
|
+
"apollographql-client-version" => "1.0"
|
|
181
|
+
},
|
|
182
|
+
body: JSON.generate(
|
|
183
|
+
query: Graphql::LIST_PARTNERS_QUERY,
|
|
184
|
+
variables: { partnershipType: "HOME_BUILDER" }
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
ensure_ok!(response, "Homebuilders request failed", "GRAPHQL_ERROR")
|
|
189
|
+
json = parse_json_response(response.body, http_status: response.code.to_i)
|
|
190
|
+
ensure_graphql_data!(json)
|
|
191
|
+
|
|
192
|
+
partners = json.dig("data", "partnership", "listPartners", "partners")
|
|
193
|
+
unless partners.is_a?(Array)
|
|
194
|
+
raise ApiError.new("GraphQL response missing partnership.listPartners.partners", "GRAPHQL_ERROR", 200)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
{
|
|
198
|
+
homebuilders: partners.map do |partner|
|
|
199
|
+
{
|
|
200
|
+
identifier: partner["identifier"],
|
|
201
|
+
displayName: partner["displayName"]
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def get_assessment_slots(offer_id:)
|
|
208
|
+
raise ValidationError.new("offerId is required", "offerId") if blank?(offer_id)
|
|
209
|
+
|
|
210
|
+
api_base = graphql_api_origin
|
|
211
|
+
url = "#{api_base}/v2/exterior_assessment_slots"
|
|
212
|
+
log("REST POST -> #{url}")
|
|
213
|
+
|
|
214
|
+
response = fetch_with_timeout(
|
|
215
|
+
url,
|
|
216
|
+
method: :post,
|
|
217
|
+
headers: {
|
|
218
|
+
"Content-Type" => "application/json",
|
|
219
|
+
"Authorization" => "Basic #{@api_key}"
|
|
220
|
+
},
|
|
221
|
+
body: JSON.generate(
|
|
222
|
+
partnerId: @api_key,
|
|
223
|
+
sellerInputUuid: offer_id,
|
|
224
|
+
calendarType: "DILIGENCE_VISIT"
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
ensure_ok!(response, "Assessment slots request failed", "NETWORK_ERROR")
|
|
228
|
+
raw = parse_json_response(response.body, http_status: response.code.to_i)
|
|
229
|
+
|
|
230
|
+
unless raw["success"]
|
|
231
|
+
raise ApiError.new(raw["error"] || "Assessment slots request failed", "API_ERROR", 200)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
{
|
|
235
|
+
currentInspectionTime: raw["currentInspectionTime"],
|
|
236
|
+
availableSlots: raw["availableSlots"] || [],
|
|
237
|
+
isSlotBased: raw["isSlotBased"] || false,
|
|
238
|
+
visitId: raw["visitId"] || "",
|
|
239
|
+
timezone: raw["timezone"] || "UTC"
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def get_home_detail(opendoor_offer_request_id:)
|
|
244
|
+
raise ValidationError.new("opendoorOfferRequestId is required", "opendoorOfferRequestId") if blank?(opendoor_offer_request_id)
|
|
245
|
+
|
|
246
|
+
data = graphql(
|
|
247
|
+
query: Graphql::SELLER_INPUT_PREFILLS_QUERY,
|
|
248
|
+
variables: { sellerInputUuid: opendoor_offer_request_id }
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
seller_prefills = data["sellerInputPrefills"]
|
|
252
|
+
raise ApiError.new("Home detail not found", "NOT_FOUND", 404) if seller_prefills.nil?
|
|
253
|
+
|
|
254
|
+
{ answerPrefills: seller_prefills["answerPrefills"] || {} }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private
|
|
258
|
+
|
|
259
|
+
def resolve_prediction(prediction, session_token)
|
|
260
|
+
if prediction["street"] && prediction["city"] && prediction["state"] && prediction["postalCode"]
|
|
261
|
+
return {
|
|
262
|
+
street1: prediction["street"],
|
|
263
|
+
street2: prediction["unitInfo"],
|
|
264
|
+
city: prediction["city"],
|
|
265
|
+
state: prediction["state"],
|
|
266
|
+
postalCode: prediction["postalCode"]
|
|
267
|
+
}.compact
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
place_id = prediction["place_id"]
|
|
271
|
+
raise "Cannot resolve prediction" if blank?(place_id)
|
|
272
|
+
|
|
273
|
+
get_place_details(place_id, session_token)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def get_place_details(place_id, session_token)
|
|
277
|
+
params = URI.encode_www_form(placeId: place_id, sessionToken: session_token)
|
|
278
|
+
url = "#{@address_endpoint}/_address-input/place?#{params}"
|
|
279
|
+
response = fetch_with_timeout(url, method: :get, headers: { "Content-Type" => "application/json" })
|
|
280
|
+
ensure_ok!(response, "Place details request failed", "NETWORK_ERROR")
|
|
281
|
+
raw = parse_json_response(response.body, http_status: response.code.to_i)
|
|
282
|
+
|
|
283
|
+
{
|
|
284
|
+
street1: raw["street1"].to_s.strip,
|
|
285
|
+
street2: raw["street2"],
|
|
286
|
+
city: raw["city"],
|
|
287
|
+
state: raw["state"],
|
|
288
|
+
postalCode: raw["postal_code"]
|
|
289
|
+
}.compact
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def graphql(query:, variables:)
|
|
293
|
+
response = fetch_with_timeout(
|
|
294
|
+
@api_endpoint,
|
|
295
|
+
method: :post,
|
|
296
|
+
headers: {
|
|
297
|
+
"Content-Type" => "application/json",
|
|
298
|
+
"Authorization" => "Basic #{@api_key}"
|
|
299
|
+
},
|
|
300
|
+
body: JSON.generate(query: query, variables: variables)
|
|
301
|
+
)
|
|
302
|
+
ensure_ok!(response, "GraphQL request failed", "GRAPHQL_ERROR")
|
|
303
|
+
|
|
304
|
+
json = parse_json_response(response.body, http_status: response.code.to_i)
|
|
305
|
+
ensure_graphql_data!(json)
|
|
306
|
+
json.fetch("data")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def ensure_graphql_data!(json)
|
|
310
|
+
if json["errors"].is_a?(Array) && !json["errors"].empty?
|
|
311
|
+
message = json["errors"].map { |error| error["message"] }.join(", ")
|
|
312
|
+
raise ApiError.new("GraphQL errors: #{message}", "GRAPHQL_ERROR", 200, json["errors"])
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
raise ApiError.new("GraphQL response missing data", "GRAPHQL_ERROR", 200) if json["data"].nil?
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def ensure_ok!(response, prefix, code)
|
|
319
|
+
status = response.code.to_i
|
|
320
|
+
return if status >= 200 && status < 300
|
|
321
|
+
|
|
322
|
+
raise ApiError.new("#{prefix}: #{status} #{response.message}", code, status)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def fetch_with_timeout(url, method:, headers:, body: nil)
|
|
326
|
+
uri = URI.parse(url)
|
|
327
|
+
request_class = method == :post ? Net::HTTP::Post : Net::HTTP::Get
|
|
328
|
+
request = request_class.new(uri.request_uri)
|
|
329
|
+
headers.each { |k, v| request[k] = v }
|
|
330
|
+
request.body = body if body
|
|
331
|
+
|
|
332
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
333
|
+
http.use_ssl = uri.scheme == "https"
|
|
334
|
+
http.open_timeout = @timeout_seconds
|
|
335
|
+
http.read_timeout = @timeout_seconds
|
|
336
|
+
http.request(request)
|
|
337
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
338
|
+
raise ApiError.new("Request timed out after #{@timeout_ms}ms", "TIMEOUT", 0)
|
|
339
|
+
rescue SocketError, Errno::ECONNREFUSED, IOError => e
|
|
340
|
+
raise ApiError.new("Network error: #{e.message}", "NETWORK_ERROR", 0)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def normalize_address(address)
|
|
344
|
+
{
|
|
345
|
+
street1: get(address, :street1),
|
|
346
|
+
street2: get(address, :street2),
|
|
347
|
+
city: get(address, :city),
|
|
348
|
+
state: get(address, :state),
|
|
349
|
+
postalCode: get(address, :postal_code, :postalCode)
|
|
350
|
+
}.compact
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def stringify_keys(value)
|
|
354
|
+
if value.is_a?(Hash)
|
|
355
|
+
value.each_with_object({}) do |(k, v), h|
|
|
356
|
+
h[k.to_s] = stringify_keys(v)
|
|
357
|
+
end
|
|
358
|
+
elsif value.is_a?(Array)
|
|
359
|
+
value.map { |v| stringify_keys(v) }
|
|
360
|
+
else
|
|
361
|
+
value
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def symbolize_keys(value)
|
|
366
|
+
if value.is_a?(Hash)
|
|
367
|
+
value.each_with_object({}) do |(k, v), h|
|
|
368
|
+
h[k.to_sym] = symbolize_keys(v)
|
|
369
|
+
end
|
|
370
|
+
elsif value.is_a?(Array)
|
|
371
|
+
value.map { |v| symbolize_keys(v) }
|
|
372
|
+
else
|
|
373
|
+
value
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def get(hash, *keys)
|
|
378
|
+
return nil unless hash.is_a?(Hash)
|
|
379
|
+
|
|
380
|
+
keys.each do |key|
|
|
381
|
+
return hash[key] if hash.key?(key)
|
|
382
|
+
return hash[key.to_s] if hash.key?(key.to_s)
|
|
383
|
+
end
|
|
384
|
+
nil
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def blank?(value)
|
|
388
|
+
value.nil? || value.to_s.strip.empty?
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Same-origin base as JS `new URL(apiEndpoint).origin` (includes non-default port).
|
|
392
|
+
def graphql_api_origin
|
|
393
|
+
uri = URI.parse(@api_endpoint)
|
|
394
|
+
if uri.respond_to?(:origin) && !uri.origin.nil?
|
|
395
|
+
uri.origin
|
|
396
|
+
else
|
|
397
|
+
"#{uri.scheme}://#{uri.host}#{uri.port && ![80, 443].include?(uri.port) ? ":#{uri.port}" : ""}"
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def parse_json_response(body, http_status:)
|
|
402
|
+
JSON.parse(body.to_s)
|
|
403
|
+
rescue JSON::ParserError => e
|
|
404
|
+
raise ApiError.new("Invalid JSON response: #{e.message}", "PARSE_ERROR", http_status)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def log(message)
|
|
408
|
+
puts("[OpendoorClient] #{message}") if @debug
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Opendoor
|
|
2
|
+
module PartnerSdk
|
|
3
|
+
class ValidationError < StandardError
|
|
4
|
+
attr_reader :code, :field
|
|
5
|
+
|
|
6
|
+
def initialize(message, field = nil)
|
|
7
|
+
super(message)
|
|
8
|
+
@code = "VALIDATION_ERROR"
|
|
9
|
+
@field = field
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class ApiError < StandardError
|
|
14
|
+
attr_reader :code, :status_code, :graphql_errors
|
|
15
|
+
|
|
16
|
+
def initialize(message, code, status_code, graphql_errors = nil)
|
|
17
|
+
super(message)
|
|
18
|
+
@code = code
|
|
19
|
+
@status_code = status_code
|
|
20
|
+
@graphql_errors = graphql_errors
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Opendoor
|
|
2
|
+
module PartnerSdk
|
|
3
|
+
module Graphql
|
|
4
|
+
OFFER_REQUEST_CREATE = <<~GRAPHQL.freeze
|
|
5
|
+
mutation OfferRequestCreate($offerRequestCreateInput: OfferRequestCreateInput!) {
|
|
6
|
+
offerRequestCreate(params: $offerRequestCreateInput) {
|
|
7
|
+
offerRequest {
|
|
8
|
+
opendoorOfferRequestId
|
|
9
|
+
offerStatus
|
|
10
|
+
denialInfo {
|
|
11
|
+
deniedAt
|
|
12
|
+
code
|
|
13
|
+
explanation
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
GRAPHQL
|
|
19
|
+
|
|
20
|
+
OFFER_REQUEST_UPDATE = <<~GRAPHQL.freeze
|
|
21
|
+
mutation OfferRequestUpdate($offerRequestUpdateInput: OfferRequestUpdateInput!) {
|
|
22
|
+
offerRequestUpdate(params: $offerRequestUpdateInput) {
|
|
23
|
+
offerRequest {
|
|
24
|
+
opendoorOfferRequestId
|
|
25
|
+
offerStatus
|
|
26
|
+
denialInfo {
|
|
27
|
+
deniedAt
|
|
28
|
+
code
|
|
29
|
+
explanation
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
GRAPHQL
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
module Opendoor
|
|
2
|
+
module PartnerSdk
|
|
3
|
+
module Graphql
|
|
4
|
+
OFFER_REQUEST_QUERY = <<~GRAPHQL.freeze
|
|
5
|
+
query OfferRequest($opendoorOfferRequestId: String!) {
|
|
6
|
+
offerRequest(opendoorOfferRequestId: $opendoorOfferRequestId) {
|
|
7
|
+
opendoorOfferRequestId
|
|
8
|
+
offerStatus
|
|
9
|
+
denialInfo {
|
|
10
|
+
deniedAt
|
|
11
|
+
code
|
|
12
|
+
explanation
|
|
13
|
+
}
|
|
14
|
+
offerData {
|
|
15
|
+
headlinePriceCents
|
|
16
|
+
headlineValuationRangeStart
|
|
17
|
+
headlineValuationRangeEnd
|
|
18
|
+
feeCostCents
|
|
19
|
+
feeCostPercent
|
|
20
|
+
estimatedClosingCostCents
|
|
21
|
+
estimatedClosingCostPercent
|
|
22
|
+
repairCostCents
|
|
23
|
+
url
|
|
24
|
+
authenticatedDashboardUrl
|
|
25
|
+
offerSource
|
|
26
|
+
sentAt
|
|
27
|
+
expirationAt
|
|
28
|
+
expirationAtDisplayDate
|
|
29
|
+
underwritingCompletedAt
|
|
30
|
+
underwritingState
|
|
31
|
+
enteredContractAt
|
|
32
|
+
coeDate
|
|
33
|
+
closedAt
|
|
34
|
+
withdrawnAt
|
|
35
|
+
priceAlert
|
|
36
|
+
}
|
|
37
|
+
handoverAt
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
GRAPHQL
|
|
41
|
+
|
|
42
|
+
OFFER_REQUEST_WITH_SELL_DIRECT_PRICING = <<~GRAPHQL.freeze
|
|
43
|
+
query OfferRequestWithSellDirectPricing($opendoorOfferRequestId: String!) {
|
|
44
|
+
offerRequestWithSellDirectPricing(opendoorOfferRequestId: $opendoorOfferRequestId) {
|
|
45
|
+
opendoorOfferRequestId
|
|
46
|
+
offerStatus
|
|
47
|
+
denialInfo {
|
|
48
|
+
deniedAt
|
|
49
|
+
code
|
|
50
|
+
explanation
|
|
51
|
+
}
|
|
52
|
+
offerData {
|
|
53
|
+
netProceeds
|
|
54
|
+
sentAt
|
|
55
|
+
expirationAt
|
|
56
|
+
expirationAtDisplayDate
|
|
57
|
+
mortgageBalance
|
|
58
|
+
sellerName
|
|
59
|
+
sellerEmail
|
|
60
|
+
headlinePriceCents
|
|
61
|
+
opendoorFeeCents
|
|
62
|
+
repairCostCents
|
|
63
|
+
closingCostsCents
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
GRAPHQL
|
|
68
|
+
|
|
69
|
+
ADDRESS_UNIT_CHECK = <<~GRAPHQL.freeze
|
|
70
|
+
query AddressUnitCheck($address: AddressInput!) {
|
|
71
|
+
addressUnitCheck(address: $address) {
|
|
72
|
+
requiresUnitNumber
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
GRAPHQL
|
|
76
|
+
|
|
77
|
+
LIST_PARTNERS_QUERY = <<~GRAPHQL.freeze
|
|
78
|
+
query ListPartnersQuery($partnershipType: OdProtosPartnershipData_PartnershipType!) {
|
|
79
|
+
partnership {
|
|
80
|
+
listPartners(input: { partnershipType: $partnershipType }) {
|
|
81
|
+
partners {
|
|
82
|
+
identifier
|
|
83
|
+
displayName
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
GRAPHQL
|
|
89
|
+
|
|
90
|
+
ADDRESS_DETAILS_QUERY = <<~GRAPHQL.freeze
|
|
91
|
+
query Address($address: AddressInput!, $trackingId: String) {
|
|
92
|
+
address(address: $address) {
|
|
93
|
+
products {
|
|
94
|
+
homeSell {
|
|
95
|
+
isEligible
|
|
96
|
+
denialCode
|
|
97
|
+
denialExplanation
|
|
98
|
+
valueEstimate {
|
|
99
|
+
lower
|
|
100
|
+
upper
|
|
101
|
+
}
|
|
102
|
+
referral(trackingId: $trackingId) {
|
|
103
|
+
url
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
GRAPHQL
|
|
110
|
+
|
|
111
|
+
SELLER_INPUT_PREFILLS_QUERY = <<~GRAPHQL.freeze
|
|
112
|
+
query SellerInputPrefills($sellerInputUuid: String!) {
|
|
113
|
+
sellerInputPrefills(sellerInputUuid: $sellerInputUuid) {
|
|
114
|
+
answerPrefills
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
GRAPHQL
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Opendoor
|
|
2
|
+
module PartnerSdk
|
|
3
|
+
module Validators
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def validate_address(address)
|
|
7
|
+
raise ValidationError.new("street1 is required", "address.street1") if blank?(get(address, :street1))
|
|
8
|
+
raise ValidationError.new("city is required", "address.city") if blank?(get(address, :city))
|
|
9
|
+
raise ValidationError.new("state is required", "address.state") if blank?(get(address, :state))
|
|
10
|
+
raise ValidationError.new("postalCode is required", "address.postalCode") if blank?(get(address, :postal_code, :postalCode))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def validate_create_offer_request(data)
|
|
14
|
+
unless data.is_a?(Hash)
|
|
15
|
+
raise ValidationError.new("create data must be a Hash", "data")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
address = get(data, :address)
|
|
19
|
+
raise ValidationError.new("address is required", "address") if address.nil?
|
|
20
|
+
raise ValidationError.new("address must be a Hash", "address") unless address.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
validate_address(address)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_update_offer_request(offer_id, data)
|
|
26
|
+
raise ValidationError.new("offerId is required", "offerId") if blank?(offer_id)
|
|
27
|
+
unless data.is_a?(Hash)
|
|
28
|
+
raise ValidationError.new("update data must be a Hash", "data")
|
|
29
|
+
end
|
|
30
|
+
raise ValidationError.new(
|
|
31
|
+
"At least one OfferInput field must be provided",
|
|
32
|
+
"offerInput"
|
|
33
|
+
) if data.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def blank?(value)
|
|
37
|
+
value.nil? || value.to_s.strip.empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get(hash, *keys)
|
|
41
|
+
return nil unless hash.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
keys.each do |key|
|
|
44
|
+
[key, key.to_s].uniq.each do |k|
|
|
45
|
+
next unless hash.key?(k)
|
|
46
|
+
|
|
47
|
+
v = hash[k]
|
|
48
|
+
return v unless v.nil?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require_relative "partner_sdk/version"
|
|
2
|
+
require_relative "partner_sdk/errors"
|
|
3
|
+
require_relative "partner_sdk/validators"
|
|
4
|
+
require_relative "partner_sdk/answer_key_mapping"
|
|
5
|
+
require_relative "partner_sdk/graphql/queries"
|
|
6
|
+
require_relative "partner_sdk/graphql/mutations"
|
|
7
|
+
require_relative "partner_sdk/client"
|
|
8
|
+
|
|
9
|
+
module Opendoor
|
|
10
|
+
module PartnerSdk
|
|
11
|
+
end
|
|
12
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: opendoor-partner-sdk-server-ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.1.6.beta.89.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Opendoor
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-12 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.22'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.22'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.2'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.2'
|
|
41
|
+
description: Ruby SDK for partner backend integrations with Opendoor APIs. Distributed
|
|
42
|
+
as a packaged gem only; see LICENSE.txt. Source maintained by Opendoor Labs is proprietary.
|
|
43
|
+
email:
|
|
44
|
+
- partnerships@opendoor.com
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- LICENSE.txt
|
|
50
|
+
- README.md
|
|
51
|
+
- lib/opendoor/partner_sdk.rb
|
|
52
|
+
- lib/opendoor/partner_sdk/answer_key_mapping.rb
|
|
53
|
+
- lib/opendoor/partner_sdk/client.rb
|
|
54
|
+
- lib/opendoor/partner_sdk/errors.rb
|
|
55
|
+
- lib/opendoor/partner_sdk/graphql/mutations.rb
|
|
56
|
+
- lib/opendoor/partner_sdk/graphql/queries.rb
|
|
57
|
+
- lib/opendoor/partner_sdk/validators.rb
|
|
58
|
+
- lib/opendoor/partner_sdk/version.rb
|
|
59
|
+
homepage: https://partner-sdk.opendoor.com/
|
|
60
|
+
licenses:
|
|
61
|
+
- NONE
|
|
62
|
+
metadata:
|
|
63
|
+
homepage_uri: https://partner-sdk.opendoor.com/
|
|
64
|
+
documentation_uri: https://partner-sdk.opendoor.com/
|
|
65
|
+
post_install_message:
|
|
66
|
+
rdoc_options: []
|
|
67
|
+
require_paths:
|
|
68
|
+
- lib
|
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: 2.7.0
|
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '0'
|
|
79
|
+
requirements: []
|
|
80
|
+
rubygems_version: 3.5.22
|
|
81
|
+
signing_key:
|
|
82
|
+
specification_version: 4
|
|
83
|
+
summary: Server-side Ruby SDK for Opendoor partner integrations
|
|
84
|
+
test_files: []
|