···11+## [0.3.1] - 2025-12-19
22+33+- allow passing a DID string or object to `#resolve_handle` and just return that DID โ so you can have a script that accepts either a handle or a DID, and passes the input to `DID.resolve_handle` without checking which one it is
44+- allow passing another DID object to `DID.new` and return a copy of that DID
55+- parse `seq` field in `PLCOperation` if included and expose it as a property
66+- fixed some errors on Rubies older than 3.2 due to missing `filter_map` and `URI#origin`
77+- `PLCOperation` verifies if the argument is a `Hash`
88+99+## [0.3.0] - 2025-12-15
1010+1111+Breaking changes:
1212+1313+* removed `DID#is_known_by_relay?` โ it doesn't work anymore, since relays are now non-archival and they expose almost no XRPC routes
1414+* renamed a few handle-related methods:
1515+ - `get_validated_handle` -> `get_verified_handle`
1616+ - `pick_valid_handle` -> `first_verified_handle`
1717+1818+Also:
1919+2020+- added `DID#account_status` method, which checks `getRepoStatus` endpoint to tell if an account is active, deactivated, taken down etc.
2121+- added `DID#account_active?` helper (`account_status == :active`)
2222+- `DID#account_exists?` now calls `getRepoStatus` (via `account_status`, checking if it's not nil) instead of `getLatestCommit`
2323+- added `DID#document` which keeps a memoized copy of the document
2424+- added `pds_host` & `labeler_host` methods to `PLCOperation` and `Document`, which return the PDS/labeller address without the `https://`
2525+- added `labeller_endpoint` & `labeller_host` aliases for the double-L enjoyers :]
2626+- added `PLCOperation#cid`
2727+- `PLCImporter` now removes duplicate operations at the edge of pages returned from the `/export` API
2828+- rewritten some networking code โ all classes now use `Net::HTTP` with consistent options instead of `open-uri`
2929+3030+Note: `PLCImporter` will be rewritten soon to add support for updated [plc.directory](https://plc.directory) APIs, so be prepared for some breaking changes there in v. 0.4.
3131+3232+## [0.2.3] - 2024-07-02
3333+3434+- added a `DID#get_audit_log` method that fetches the PLC audit log for a DID
3535+- added a way to set an error handler in `PLCImporter`
3636+- reverted the change from 0.2.1 that added Ruby stdlib dependencies explicitly to the gemspec, since this causes more problems than it's worth
3737+- minor bug fixes
3838+3939+## [0.2.2] - 2024-04-01
4040+4141+- added helpers for checking if a DID is known by (federated with) a relay or if the repo exists on its assigned PDS
4242+143## [0.2.1] - 2024-03-26
244345- tweaked validations in `Document` and `PLCOperation` to make them more aligned with what might be expected
···11The zlib License
2233-Copyright (c) 2023 Jakub Suder
33+Copyright (c) 2026 Jakub Suder
4455This software is provided 'as-is', without any express or implied
66warranty. In no event will the authors be held liable for any damages
+67-36
README.md
···11-# DIDKit
11+# DIDKit ๐ชช
22+33+A small Ruby gem for handling Distributed Identifiers (DIDs) in Bluesky / AT Protocol.
2433-A small Ruby gem for handling Distributed Identifiers (DIDs) in Bluesky / AT Protocol
55+> [!NOTE]
66+> Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue)
475869## What does it do
···10131114## Installation
12151313- gem install didkit
1616+To use DIDKit, you need a reasonably new version of Ruby โ it should run on Ruby 2.6 and above, although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.2+. A compatible version should be preinstalled on macOS Big Sur and above and on many Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)).
14171818+To install the gem, run in the command line:
15191616-## Usage
2020+ [sudo] gem install didkit
17211818-Use the `DIDKit::Resolver` class to look up DIDs and handles.
2222+Or add this to your app's `Gemfile`:
19232020-To look up a handle:
2424+ gem 'didkit', '~> 0.3'
2525+2626+2727+## Usage
2828+2929+The simplest way to use the gem is through the `DIDKit::DID` class, aliased as just `DID`:
21302231```rb
2323-resolver = DIDKit::Resolver.new
2424-resolver.resolve_handle('nytimes.com')
2525- # => #<DIDKit::DID:0x00000001035956b0 @did="did:plc:eclio37ymobqex2ncko63h4r", @type=:plc, @resolved_by=:dns>
3232+did = DID.resolve_handle('jay.bsky.team')
3333+ # => #<DIDKit::DID:0x0... @did="did:plc:oky5czdrnfjpqslsw2a5iclo",
3434+ # @resolved_by=:dns, @type=:plc>
2635```
27362828-This returns an object of `DIDKit::DID` class (aliased as just `DID`), which tells you:
3737+This returns a `DID` object, which tells you:
29383039- the DID as a string (`#to_s` or `#did`)
3140- the DID type (`#type`, `:plc` or `:web`)
3241- if the handle was resolved via a DNS entry or a `.well-known` file (`#resolved_by`, `:dns` or `:http`)
33423434-To go in the other direction โ to find an assigned and verified handle given a DID โ use `get_validated_handle` (pass DID as a string or an object):
4343+To go in the other direction โ to find an assigned and verified handle given a DID โ create a `DID` from a DID string and call `get_verified_handle`:
35443645```rb
3737-resolver.get_validated_handle('did:plc:ewvi7nxzyoun6zhxrhs64oiz')
3838- # => "atproto.com"
4646+DID.new('did:plc:ewvi7nxzyoun6zhxrhs64oiz').get_verified_handle
4747+ # => "atproto.com"
3948```
40494141-You can also load the DID document using `resolve_did`:
5050+You can also load the DID JSON document using `#document`, which returns a `DIDKit::Document` (`DID` caches the document, so don't worry about calling this method multiple times):
42514352```rb
4444-doc = resolver.resolve_did('did:plc:ragtjsm2j2vknwkz3zp4oxrd')
4545- # => #<DIDKit::Document:0x0000000105d751f8 @did=#<DIDKit::DID:...>, @json={...}>
5353+did = DID.new('did:plc:ragtjsm2j2vknwkz3zp4oxrd')
46544747-doc.handles
4848- # => ["pfrazee.com"]
5555+did.document.handles
5656+ # => ["pfrazee.com"]
49575050-doc.pds_endpoint
5151- # => "https://morel.us-east.host.bsky.network"
5858+did.document.pds_host
5959+ # => "morel.us-east.host.bsky.network"
5260```
53615454-There are also some helper methods in the `DID` class that create a `Resolver` for you to save you some typing:
6262+6363+### Checking account status
6464+6565+`DIDKit::DID` also includes a few methods for checking the status of a given account (repo), which call the `com.atproto.sync.getRepoStatus` endpoint on the account's assigned PDS:
55665667```rb
5757-did = DID.resolve_handle('jay.bsky.team')
5858- # => #<DIDKit::DID:0x000000010615ed28 @did="did:plc:oky5czdrnfjpqslsw2a5iclo", @type=:plc, @resolved_by=:dns>
6868+did = DID.new('did:plc:ch7azdejgddtlijyzurfdihn')
6969+did.account_status
7070+ # => :takendown
7171+did.account_active?
7272+ # => false
7373+did.account_exists?
7474+ # => true
59756060-did.to_s
6161- # => "did:plc:oky5czdrnfjpqslsw2a5iclo"
7676+did = DID.new('did:plc:44ybard66vv44zksje25o7dz')
7777+did.account_status
7878+ # => :active
7979+did.account_active?
8080+ # => true
8181+```
62826363-did.get_document
6464- # => #<DIDKit::Document:0x00000001066d4898 @did=#<DIDKit::DID:...>, @json={...}>
8383+### Configuration
8484+8585+You can customize some things about the DID/handle lookups by using the `DIDKit::Resolver` class, which the methods in `DID` use behind the scenes.
8686+8787+Currently available options include:
8888+8989+- `:nameserver` - override the nameserver used for DNS lookups, e.g. to use Google's or CloudFlare's DNS
9090+- `:timeout` - change the connection/response timeout for HTTP requests (default: 15 s)
9191+- `:max_redirects` - change allowed maximum number of redirects (default: 5)
65926666-did.get_validated_handle
6767- # => "jay.bsky.team"
6868-```
9393+Example:
69949595+```rb
9696+resolver = DIDKit::Resolver.new(nameserver: '8.8.8.8', timeout: 30)
70977171-### Configuration
9898+did = resolver.resolve_handle('nytimes.com')
9999+ # => #<DIDKit::DID:0x0... @did="did:plc:eclio37ymobqex2ncko63h4r",
100100+ # @resolved_by=:dns, @type=:plc>
721017373-You can override the nameserver used for DNS lookups by setting the `nameserver` property in `Resolver`, e.g. to use Google's or CloudFlare's global DNS:
102102+resolver.resolve_did(did)
103103+ # => #<DIDKit::Document:0x0... @did=#<DIDKit::DID:...>, @json={...}>
741047575-```
7676-resolver.nameserver = '8.8.8.8'
105105+resolver.get_verified_handle(did)
106106+ # => 'nytimes.com'
77107```
7878-7910880109## Credits
811108282-Copyright ยฉ 2024 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
111111+Copyright ยฉ 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
8311284113The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
114114+115115+Bug reports and pull requests are welcome ๐
···11+require 'json'
22+require 'uri'
33+14require_relative 'errors'
55+require_relative 'requests'
26require_relative 'resolver'
3748module DIDKit
99+1010+ #
1111+ # Represents a DID identifier (account on the ATProto network). This class serves as an entry
1212+ # point to various lookup helpers. For convenience it can also be accessed as just `DID` without
1313+ # the `DIDKit::` prefix.
1414+ #
1515+ # @example Resolving a handle
1616+ # did = DID.resolve_handle('bsky.app')
1717+ #
1818+519 class DID
2020+ GENERIC_REGEXP = /\Adid\:\w+\:.+\z/
2121+2222+ include Requests
2323+2424+ # Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method
2525+ # and the HTTP .well-known method and returns a DID if one is assigned using either of the methods.
2626+ #
2727+ # If a DID string or a {DID} object is passed, it simply returns that DID, so you can use this
2828+ # method to pass it an input string from the user which can be a DID or handle, without having to
2929+ # check which one it is.
3030+ #
3131+ # @param handle [String, DID] a domain handle (may start with an `@`) or a DID string
3232+ # @return [DID, nil] resolved DID if found, nil otherwise
3333+634 def self.resolve_handle(handle)
735 Resolver.new.resolve_handle(handle)
836 end
9371010- attr_reader :type, :did, :resolved_by
3838+ # @return [Symbol] DID type (`:plc` or `:web`)
3939+ attr_reader :type
4040+4141+ # @return [String] DID identifier string
4242+ attr_reader :did
4343+4444+ # @return [Symbol, nil] `:dns` or `:http` if the DID was looked up using one of those methods
4545+ attr_reader :resolved_by
4646+4747+ alias to_s did
4848+4949+5050+ # Create a DID object from a DID string.
5151+ #
5252+ # @param did [String, DID] DID string or another DID object
5353+ # @param resolved_by [Symbol, nil] optionally, how the DID was looked up (`:dns` or `:http`)
5454+ # @raise [DIDError] when the DID format or type is invalid
11551256 def initialize(did, resolved_by = nil)
1313- if did =~ /^did\:(\w+)\:/
5757+ if did.is_a?(DID)
5858+ did = did.to_s
5959+ end
6060+6161+ if did =~ GENERIC_REGEXP
1462 @did = did
1515- @type = $1.to_sym
6363+ @type = did.split(':')[1].to_sym
1664 else
1765 raise DIDError.new("Invalid DID format")
1866 end
···2472 @resolved_by = resolved_by
2573 end
26742727- alias to_s did
7575+ # Returns or looks up the DID document with the DID's identity details from an appropriate source.
7676+ # This method caches the document in a local variable if it's called again.
7777+ #
7878+ # @return [Document] resolved DID document
7979+8080+ def document
8181+ @document ||= get_document
8282+ end
8383+8484+ # Looks up the DID document with the DID's identity details from an appropriate source.
8585+ # @return [Document] resolved DID document
28862987 def get_document
3088 Resolver.new.resolve_did(self)
3189 end
32903333- def get_validated_handle
3434- Resolver.new.get_validated_handle(self)
9191+ # Returns the first verified handle assigned to this DID.
9292+ #
9393+ # Looks up the domain handles assigned to this DID in its DID document, checks if they are
9494+ # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
9595+ # the first handle that validates correctly, or nil if none matches.
9696+ #
9797+ # @return [String, nil] verified handle domain, if found
9898+9999+ def get_verified_handle
100100+ Resolver.new.get_verified_handle(document)
35101 end
36102103103+ # Fetches the PLC audit log (list of all previous operations) for a did:plc DID.
104104+ #
105105+ # @return [Array<PLCOperation>] list of PLC operations in the audit log
106106+ # @raise [DIDError] when the DID is not a did:plc
107107+108108+ def get_audit_log
109109+ if @type == :plc
110110+ PLCImporter.new.fetch_audit_log(self)
111111+ else
112112+ raise DIDError.new("Audit log not supported for did:#{@type}")
113113+ end
114114+ end
115115+116116+ # Returns the domain portion of a did:web identifier.
117117+ #
118118+ # @return [String, nil] DID domain if the DID is a did:web, nil for did:plc
119119+37120 def web_domain
38121 did.gsub(/^did\:web\:/, '') if type == :web
39122 end
123123+124124+ # Checks the status of the account/repo on its own PDS using the `getRepoStatus` endpoint.
125125+ #
126126+ # @param request_options [Hash] request options to override
127127+ # @option request_options [Integer] :timeout request timeout (default: 15)
128128+ # @option request_options [Integer] :max_redirects maximum number of redirects to follow (default: 5)
129129+ #
130130+ # @return [Symbol, nil] `:active`, or returned inactive status, or `nil` if account is not found
131131+ # @raise [APIError] when the response is invalid
132132+133133+ def account_status(request_options = {})
134134+ doc = self.document
135135+ return nil if doc.pds_endpoint.nil?
136136+137137+ pds_host = uri_origin(doc.pds_endpoint)
138138+ url = URI("#{pds_host}/xrpc/com.atproto.sync.getRepoStatus")
139139+ url.query = URI.encode_www_form(:did => @did)
140140+141141+ response = get_response(url, request_options)
142142+ status = response.code.to_i
143143+ is_json = (response['Content-Type'] =~ /^application\/json(;.*)?$/)
144144+145145+ if status == 200 && is_json
146146+ json = JSON.parse(response.body)
147147+148148+ if json['active'] == true
149149+ :active
150150+ elsif json['active'] == false && json['status'].is_a?(String) && json['status'].length <= 100
151151+ json['status'].to_sym
152152+ else
153153+ raise APIError.new(response)
154154+ end
155155+ elsif status == 400 && is_json && JSON.parse(response.body)['error'] == 'RepoNotFound'
156156+ nil
157157+ else
158158+ raise APIError.new(response)
159159+ end
160160+ end
161161+162162+ # Checks if the account is seen as active on its own PDS, using the `getRepoStatus` endpoint.
163163+ # This is a helper which calls the {#account_status} method and checks if the status is `:active`.
164164+ #
165165+ # @return [Boolean] true if the returned status is active
166166+ # @raise [APIError] when the response is invalid
167167+168168+ def account_active?
169169+ account_status == :active
170170+ end
171171+172172+ # Checks if the account exists its own PDS, using the `getRepoStatus` endpoint.
173173+ # This is a helper which calls the {#account_status} method and checks if the repo is found at all.
174174+ #
175175+ # @return [Boolean] true if the returned status is valid, false if repo is not found
176176+ # @raise [APIError] when the response is invalid
177177+178178+ def account_exists?
179179+ account_status != nil
180180+ end
181181+182182+ # Compares the DID to another DID object or string.
183183+ #
184184+ # @param other [DID, String] other DID to compare with
185185+ # @return [Boolean] true if it's the same DID
4018641187 def ==(other)
42188 if other.is_a?(String)
+59-18
lib/didkit/document.rb
···11require_relative 'at_handles'
22+require_relative 'errors'
23require_relative 'resolver'
34require_relative 'service_record'
45require_relative 'services'
5667module DIDKit
77- class Document
88- class FormatError < StandardError
99- end
88+99+ #
1010+ # Parsed DID document from a JSON file loaded from [plc.directory](https://plc.directory) or a did:web domain.
1111+ #
1212+ # Use {DID#document} or {Resolver#resolve_did} to fetch a DID document and return this object.
1313+ #
10141515+ class Document
1116 include AtHandles
1217 include Services
13181414- attr_reader :json, :did, :handles, :services
1919+ # @return [Hash] the complete JSON data of the DID document
2020+ attr_reader :json
2121+2222+ # @return [DID] the DID that this document describes
2323+ attr_reader :did
2424+2525+ # Returns a list of handles assigned to this DID in its DID document.
2626+ #
2727+ # Note: the handles aren't guaranteed to be verified (validated in the other direction).
2828+ # Use {#get_verified_handle} to find a handle that is correctly verified.
2929+ #
3030+ # @return [Array<String>]
3131+ attr_reader :handles
3232+3333+ # @return [Array<ServiceRecords>] service records like PDS details assigned to the DID
3434+ attr_reader :services
3535+3636+ # Creates a DID document object.
3737+ #
3838+ # @param did [DID] DID object
3939+ # @param json [Hash] DID document JSON
4040+ # @raise [FormatError] when required fields are missing or invalid.
15411642 def initialize(did, json)
1743 raise FormatError, "Missing id field" if json['id'].nil?
···2147 @did = did
2248 @json = json
23492424- if service = json['service']
2525- raise FormatError, "Invalid service data" unless service.is_a?(Array) && service.all? { |x| x.is_a?(Hash) }
5050+ @services = parse_services(json['service'] || [])
5151+ @handles = parse_also_known_as(json['alsoKnownAs'] || [])
5252+ end
5353+5454+ # Returns the first verified handle assigned to the DID.
5555+ #
5656+ # Looks up the domain handles assigned to this DID in the DID document, checks if they are
5757+ # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
5858+ # the first handle that validates correctly, or nil if none matches.
5959+ #
6060+ # @return [String, nil] verified handle domain, if found
26612727- @services = service.filter_map { |x|
2828- id, type, endpoint = x.values_at('id', 'type', 'serviceEndpoint')
2929- next unless id.is_a?(String) && id.start_with?('#') && type.is_a?(String) && endpoint.is_a?(String)
6262+ def get_verified_handle
6363+ Resolver.new.get_verified_handle(self)
6464+ end
30653131- ServiceRecord.new(id.gsub(/^#/, ''), type, endpoint)
3232- }
3333- else
3434- @services = []
3535- end
36663737- @handles = parse_also_known_as(json['alsoKnownAs'] || [])
3838- end
6767+ private
6868+6969+ def parse_services(service_data)
7070+ raise FormatError, "Invalid service data" unless service_data.is_a?(Array) && service_data.all? { |x| x.is_a?(Hash) }
7171+7272+ services = []
7373+7474+ service_data.each do |x|
7575+ id, type, endpoint = x.values_at('id', 'type', 'serviceEndpoint')
7676+7777+ if id.is_a?(String) && id.start_with?('#') && type.is_a?(String) && endpoint.is_a?(String)
7878+ services << ServiceRecord.new(id.gsub(/^#/, ''), type, endpoint)
7979+ end
8080+ end
39814040- def get_validated_handle
4141- Resolver.new.pick_valid_handle(did, handles)
8282+ services
4283 end
4384 end
4485end
+35
lib/didkit/errors.rb
···11module DIDKit
22+33+ #
44+ # Raised when an HTTP request returns a response with an error status.
55+ #
66+ class APIError < StandardError
77+88+ # @return [Net::HTTPResponse] the returned HTTP response
99+ attr_reader :response
1010+1111+ # @param response [Net::HTTPResponse] the returned HTTP response
1212+ def initialize(response)
1313+ @response = response
1414+ super("APIError: #{response}")
1515+ end
1616+1717+ # @return [Integer] HTTP status code
1818+ def status
1919+ response.code.to_i
2020+ end
2121+2222+ # @return [String] HTTP response body
2323+ def body
2424+ response.body
2525+ end
2626+ end
2727+2828+ #
2929+ # Raised when a string is not a valid DID or not of the right type.
3030+ #
231 class DIDError < StandardError
3232+ end
3333+3434+ #
3535+ # Raised when the loaded data has some missing or invalid fields.
3636+ #
3737+ class FormatError < StandardError
338 end
439end
+38-8
lib/didkit/plc_importer.rb
···11require 'json'
22-require 'open-uri'
32require 'time'
33+require 'uri'
4455require_relative 'plc_operation'
66+require_relative 'requests'
77+88+#
99+# NOTE: this class is pending a rewrite once new APIs are deployed to plc.directory.
1010+# Things will change here in v. 0.4.
1111+#
612713module DIDKit
814 class PLCImporter
915 PLC_SERVICE = 'plc.directory'
1016 MAX_PAGE = 1000
11171212- attr_accessor :ignore_errors, :last_date
1818+ include Requests
1919+2020+ attr_accessor :ignore_errors, :last_date, :error_handler
13211422 def initialize(since: nil)
1523 if since.to_s == 'beginning'
···2331 @eof = true
2432 end
25332626- @ignore_errors = false
3434+ @last_page_cids = []
2735 end
28362937 def plc_service
3038 PLC_SERVICE
3139 end
32404141+ def ignore_errors=(val)
4242+ @ignore_errors = val
4343+4444+ if val
4545+ @error_handler = proc { |e, j| "(ignore error)" }
4646+ else
4747+ @error_handler = nil
4848+ end
4949+ end
5050+3351 def get_export(args = {})
3452 url = URI("https://#{plc_service}/export")
3553 url.query = URI.encode_www_form(args)
36543737- data = URI.open(url).read
5555+ data = get_data(url, content_type: 'application/jsonlines')
3856 data.lines.map(&:strip).reject(&:empty?).map { |x| JSON.parse(x) }
3957 end
40585959+ def fetch_audit_log(did)
6060+ json = get_json("https://#{plc_service}/#{did}/log/audit", :content_type => :json)
6161+ json.map { |j| PLCOperation.new(j) }
6262+ end
6363+4164 def fetch_page
4265 request_time = Time.now
43664467 query = @last_date ? { :after => @last_date.utc.iso8601(6) } : {}
4568 rows = get_export(query)
46694747- operations = rows.filter_map do |json|
7070+ operations = rows.filter_map { |json|
4871 begin
4972 PLCOperation.new(json)
5050- rescue PLCOperation::FormatError => e
5151- ignore_errors ? nil : raise
7373+ rescue PLCOperation::FormatError, AtHandles::FormatError, ServiceRecord::FormatError => e
7474+ @error_handler ? @error_handler.call(e, json) : raise
7575+ nil
5276 end
5353- end
7777+ }.reject { |op|
7878+ # when you pass the most recent op's timestamp to ?after, it will be returned as the first op again,
7979+ # so we need to use this CID list to filter it out (so pages will usually be 999 items long)
8080+8181+ @last_page_cids.include?(op.cid)
8282+ }
54835584 @last_date = operations.last&.created_at || request_time
8585+ @last_page_cids = Set.new(operations.map(&:cid))
5686 @eof = (rows.length < MAX_PAGE)
57875888 operations
+52-5
lib/didkit/plc_operation.rb
···11require 'time'
2233require_relative 'at_handles'
44+require_relative 'errors'
45require_relative 'service_record'
56require_relative 'services'
6778module DIDKit
99+1010+ #
1111+ # Represents a single operation of changing a specific DID's data in the [plc.directory](https://plc.directory)
1212+ # (e.g. changing assigned handles or migrating to a different PDS).
1313+ #
1414+815 class PLCOperation
99- class FormatError < StandardError
1010- end
1111-1216 include AtHandles
1317 include Services
14181515- attr_reader :json, :did, :created_at, :type, :handles, :services
1919+ # @return [Hash] the JSON from which the operation is parsed
2020+ attr_reader :json
2121+2222+ # @return [String] the DID which the operation concerns
2323+ attr_reader :did
2424+2525+ # @return [String] CID (Content Identifier) of the operation
2626+ attr_reader :cid
2727+2828+ # Returns a sequential number of the operation (only used in the new export API).
2929+ # @return [Integer, nil] sequential number of the operation
3030+ attr_reader :seq
3131+3232+ # @return [Time] time when the operation was created
3333+ attr_reader :created_at
3434+3535+ # Returns the `type` field of the operation (usually `"plc_operation"`).
3636+ # @return [String] the operation type
3737+ attr_reader :type
3838+3939+ # Returns a list of handles assigned to the DID in this operation.
4040+ #
4141+ # Note: the handles aren't guaranteed to be verified (validated in the other direction).
4242+ # Use {DID#get_verified_handle} or {Document#get_verified_handle} to find a handle that is
4343+ # correctly verified.
4444+ #
4545+ # @return [Array<String>]
4646+ attr_reader :handles
4747+4848+ # @return [Array<ServiceRecords>] service records like PDS details assigned to the DID
4949+ attr_reader :services
5050+5151+5252+ # Creates a PLCOperation object.
5353+ #
5454+ # @param json [Hash] operation JSON
5555+ # @raise [FormatError] when required fields are missing or invalid
16561757 def initialize(json)
1858 @json = json
5959+ raise FormatError, "Expected argument to be a Hash, got a #{json.class}" unless @json.is_a?(Hash)
6060+6161+ @seq = json['seq']
1962 @did = json['did']
2063 raise FormatError, "Missing DID: #{json}" if @did.nil?
2121- raise FormatError, "Invalid DID: #{@did}" unless @did.is_a?(String) && @did.start_with?('did:')
6464+ raise FormatError, "Invalid DID: #{@did.inspect}" unless @did.is_a?(String) && @did.start_with?('did:')
6565+6666+ @cid = json['cid']
6767+ raise FormatError, "Missing CID: #{json}" if @cid.nil?
6868+ raise FormatError, "Invalid CID: #{@cid}" unless @cid.is_a?(String)
22692370 timestamp = json['createdAt']
2471 raise FormatError, "Missing createdAt: #{json}" if timestamp.nil?
+94
lib/didkit/requests.rb
···11+require 'json'
22+require 'net/http'
33+require 'uri'
44+55+require_relative 'errors'
66+77+module DIDKit
88+99+ #
1010+ # @private
1111+ #
1212+1313+ module Requests
1414+1515+ private
1616+1717+ def get_response(url, options = {})
1818+ url = URI(url) unless url.is_a?(URI)
1919+2020+ timeout = options[:timeout] || 15
2121+2222+ request_options = {
2323+ use_ssl: true,
2424+ open_timeout: timeout,
2525+ read_timeout: timeout
2626+ }
2727+2828+ redirects = 0
2929+ visited_urls = []
3030+ max_redirects = options[:max_redirects] || 5
3131+3232+ loop do
3333+ visited_urls << url
3434+3535+ response = Net::HTTP.start(url.host, url.port, request_options) do |http|
3636+ request = Net::HTTP::Get.new(url)
3737+ http.request(request)
3838+ end
3939+4040+ if response.is_a?(Net::HTTPRedirection) && redirects < max_redirects && (location = response['Location'])
4141+ url = URI(location.include?('://') ? location : (uri_origin(url) + location))
4242+4343+ if visited_urls.include?(url)
4444+ return response
4545+ else
4646+ redirects += 1
4747+ end
4848+ else
4949+ return response
5050+ end
5151+ end
5252+ end
5353+5454+ def get_data(url, options = {})
5555+ content_type = options.delete(:content_type)
5656+ response = get_response(url, options)
5757+5858+ if response.is_a?(Net::HTTPSuccess) && content_type_matches(response, content_type) && (data = response.body)
5959+ data
6060+ else
6161+ raise APIError.new(response)
6262+ end
6363+ end
6464+6565+ def get_json(url, options = {})
6666+ JSON.parse(get_data(url, options))
6767+ end
6868+6969+ def content_type_matches(response, expected_type)
7070+ content_type = response['Content-Type']
7171+7272+ case expected_type
7373+ when String
7474+ content_type == expected_type
7575+ when Regexp
7676+ content_type =~ expected_type
7777+ when :json
7878+ content_type =~ /^application\/json(;.*)?$/
7979+ when nil
8080+ true
8181+ else
8282+ raise ArgumentError, "Invalid expected_type: #{expected_type.inspect}"
8383+ end
8484+ end
8585+8686+ # backported from https://github.com/ruby/uri/pull/30/files for older Rubies
8787+ def uri_origin(uri)
8888+ uri = uri.is_a?(URI) ? uri : URI(uri)
8989+ authority = (uri.port == uri.default_port) ? uri.host : "#{uri.host}:#{uri.port}"
9090+9191+ "#{uri.scheme}://#{authority}"
9292+ end
9393+ end
9494+end
+99-45
lib/didkit/resolver.rb
···11-require 'json'
22-require 'open-uri'
31require 'net/http'
42require 'resolv'
5364require_relative 'did'
75require_relative 'document'
66+require_relative 'requests'
8798module DIDKit
99+1010+ #
1111+ # A class which manages resolving of handles to DIDs and DIDs to DID documents.
1212+ #
1313+1014 class Resolver
1515+ # These TLDs are not allowed in ATProto handles, so the resolver returns nil for them
1616+ # without trying to look them up.
1117 RESERVED_DOMAINS = %w(alt arpa example internal invalid local localhost onion test)
1212- MAX_REDIRECTS = 5
1818+1919+ include Requests
13202121+ # @return [String, Array<String>] custom DNS nameserver(s) to use for DNS TXT lookups
1422 attr_accessor :nameserver
15232424+ # @param options [Hash] resolver options
2525+ # @option options [String, Array<String>] :nameserver custom DNS nameserver(s) to use (IP or an array of IPs)
2626+ # @option options [Integer] :timeout request timeout in seconds (default: 15)
2727+ # @option options [Integer] :max_redirects maximum number of redirects to follow (default: 5)
2828+1629 def initialize(options = {})
1730 @nameserver = options[:nameserver]
3131+ @request_options = options.slice(:timeout, :max_redirects)
1832 end
19333434+ # Resolve a handle into a DID. Looks up the given ATProto domain handle using the DNS TXT method
3535+ # and the HTTP .well-known method and returns a DID if one is assigned using either of the methods.
3636+ #
3737+ # If a DID string or a {DID} object is passed, it simply returns that DID, so you can use this
3838+ # method to pass it an input string from the user which can be a DID or handle, without having to
3939+ # check which one it is.
4040+ #
4141+ # @param handle [String, DID] a domain handle (may start with an `@`) or a DID string
4242+ # @return [DID, nil] resolved DID if found, nil otherwise
4343+2044 def resolve_handle(handle)
4545+ if handle.is_a?(DID) || handle =~ DID::GENERIC_REGEXP
4646+ return DID.new(handle)
4747+ end
4848+2149 domain = handle.gsub(/^@/, '')
22502351 return nil if RESERVED_DOMAINS.include?(domain.split('.').last)
···3159 end
3260 end
33616262+ # Tries to resolve a handle into DID using the DNS TXT method.
6363+ #
6464+ # Checks the DNS records for a given domain for an entry `_atproto.#{domain}` whose value is
6565+ # a correct DID string.
6666+ #
6767+ # @param domain [String] a domain handle to look up
6868+ # @return [String, nil] resolved DID if found, nil otherwise
6969+3470 def resolve_handle_by_dns(domain)
3535- dns_records = Resolv::DNS.open(resolv_options) { |d|
7171+ dns_records = Resolv::DNS.open(resolv_options) do |d|
3672 d.getresources("_atproto.#{domain}", Resolv::DNS::Resource::IN::TXT)
3737- }
7373+ end
38743975 if record = dns_records.first
4076 if string = record.strings.first
···4581 nil
4682 end
47834848- def resolve_handle_by_well_known(domain)
4949- resolve_handle_from_url("https://#{domain}/.well-known/atproto-did")
5050- end
5151-5252- def resolve_handle_from_url(url, redirects = 0)
5353- url = URI(url) unless url.is_a?(URI)
8484+ # Tries to resolve a handle into DID using the HTTP .well-known method.
8585+ #
8686+ # Checks the `/.well-known/atproto-did` endpoint on the given domain to see if it returns
8787+ # a text file that contains a correct DID string.
8888+ #
8989+ # @param domain [String] a domain handle to look up
9090+ # @return [String, nil] resolved DID if found, nil otherwise
54915555- response = Net::HTTP.start(url.host, url.port, use_ssl: true, open_timeout: 10, read_timeout: 10) do |http|
5656- request = Net::HTTP::Get.new(url)
5757- http.request(request)
5858- end
9292+ def resolve_handle_by_well_known(domain)
9393+ url = "https://#{domain}/.well-known/atproto-did"
9494+ response = get_response(url, @request_options)
59956060- if response.is_a?(Net::HTTPSuccess)
6161- if text = response.body
6262- return parse_did_from_well_known(text)
6363- end
6464- elsif response.is_a?(Net::HTTPRedirection) && redirects < MAX_REDIRECTS
6565- if location = response['Location']
6666- target_url = location.include?('://') ? location : (url.origin + location)
6767- return resolve_handle_from_url(target_url, redirects + 1)
6868- end
9696+ if response.is_a?(Net::HTTPSuccess) && (text = response.body)
9797+ return parse_did_from_well_known(text)
6998 end
709971100 nil
···73102 nil
74103 end
75104105105+ # Resolve a DID to a DID document.
106106+ #
107107+ # Looks up the DID document with the DID's identity details from an appropriate source, i.e. either
108108+ # [plc.directory](https://plc.directory) for did:plc DIDs, or the did:web's domain for did:web DIDs.
109109+ #
110110+ # @param did [String, DID] DID string or object
111111+ # @return [Document] resolved DID document
112112+ # @raise [APIError] if an incorrect response is returned
113113+114114+ def resolve_did(did)
115115+ did = DID.new(did) if did.is_a?(String)
116116+117117+ did.type == :plc ? resolve_did_plc(did) : resolve_did_web(did)
118118+ end
119119+120120+ # Returns the first verified handle assigned to the given DID.
121121+ #
122122+ # Looks up the domain handles assigned to the DID in the DID document, checks if they are
123123+ # verified (i.e. assigned correctly to this DID using DNS TXT or .well-known) and returns
124124+ # the first handle that validates correctly, or nil if none matches.
125125+ #
126126+ # @param subject [String, DID, Document] a DID or its DID document
127127+ # @return [String, nil] verified handle domain, if found
128128+129129+ def get_verified_handle(subject)
130130+ document = subject.is_a?(Document) ? subject : resolve_did(subject)
131131+132132+ first_verified_handle(document.did, document.handles)
133133+ end
134134+135135+ # Returns the first handle from the list that resolves back to the given DID.
136136+ #
137137+ # @param did [DID, String] DID to verify the handles against
138138+ # @param handles [Array<String>] handles to check
139139+ # @return [String, nil] a verified handle, if found
140140+141141+ def first_verified_handle(did, handles)
142142+ handles.detect { |h| resolve_handle(h) == did.to_s }
143143+ end
144144+145145+146146+ private
147147+76148 def resolv_options
77149 options = Resolv::DNS::Config.default_config_hash.dup
78150 options[:nameserver] = nameserver if nameserver
···8515786158 def parse_did_from_well_known(text)
87159 text = text.strip
8888- text.lines.length == 1 && text =~ /\Adid\:\w+\:.*\z/ ? text : nil
8989- end
9090-9191- def resolve_did(did)
9292- did = DID.new(did) if did.is_a?(String)
9393-9494- did.type == :plc ? resolve_did_plc(did) : resolve_did_web(did)
160160+ text.lines.length == 1 && text =~ DID::GENERIC_REGEXP ? text : nil
95161 end
9616297163 def resolve_did_plc(did)
9898- url = "https://plc.directory/#{did}"
9999- json = JSON.parse(URI.open(url).read)
164164+ json = get_json("https://plc.directory/#{did}", content_type: /^application\/did\+ld\+json(;.+)?$/)
100165 Document.new(did, json)
101166 end
102167103168 def resolve_did_web(did)
104104- url = "https://#{did.web_domain}/.well-known/did.json"
105105- json = JSON.parse(URI.open(url).read)
169169+ json = get_json("https://#{did.web_domain}/.well-known/did.json")
106170 Document.new(did, json)
107107- end
108108-109109- def get_validated_handle(did_or_doc)
110110- document = did_or_doc.is_a?(Document) ? did_or_doc : resolve_did(did_or_doc)
111111-112112- pick_valid_handle(document.did, document.handles)
113113- end
114114-115115- def pick_valid_handle(did, handles)
116116- handles.detect { |h| resolve_handle(h) == did }
117171 end
118172 end
119173end
+22-1
lib/didkit/service_record.rb
···22require_relative 'errors'
3344module DIDKit
55+66+ # A parsed service record from either a DID document's `service` field or a PLC directory
77+ # operation's `services` field.
88+59 class ServiceRecord
66- attr_reader :key, :type, :endpoint
1010+1111+ # Returns the service's identifier (without `#`), like "atproto_pds".
1212+ # @return [String] service's identifier
1313+ attr_reader :key
1414+1515+ # Returns the service's type field, like "AtprotoPersonalDataServer".
1616+ # @return [String] service's type
1717+ attr_reader :type
1818+1919+ # @return [String] service's endpoint URL
2020+ attr_reader :endpoint
2121+2222+ # Create a service record from DID document fields.
2323+ #
2424+ # @param key [String] service identifier (without `#`)
2525+ # @param type [String] service type
2626+ # @param endpoint [String] service endpoint URL
2727+ # @raise [FormatError] when the endpoint is not a valid URI
728829 def initialize(key, type, endpoint)
930 begin
+53
lib/didkit/services.rb
···11+require 'uri'
22+13module DIDKit
44+55+ #
66+ # @api private
77+ #
88+29 module Services
1010+1111+ # Finds a service entry matching the given key and type.
1212+ #
1313+ # @api public
1414+ # @param key [String] service key in the DID document
1515+ # @param type [String] service type identifier
1616+ # @return [ServiceRecord, nil] matching service record, if found
1717+318 def get_service(key, type)
419 @services&.detect { |s| s.key == key && s.type == type }
520 end
6212222+ # Returns the PDS service endpoint, if present.
2323+ #
2424+ # If the DID has an `#atproto_pds` service declared in its `service` section,
2525+ # returns the URL in its `serviceEndpoint` field. In other words, this is the URL
2626+ # of the PDS assigned to a given user, which stores the user's account and repo.
2727+ #
2828+ # @api public
2929+ # @return [String, nil] PDS service endpoint URL
3030+731 def pds_endpoint
832 @pds_endpoint ||= get_service('atproto_pds', 'AtprotoPersonalDataServer')&.endpoint
933 end
10343535+ # Returns the labeler service endpoint, if present.
3636+ #
3737+ # If the DID has an `#atproto_labeler` service declared in its `service` section,
3838+ # returns the URL in its `serviceEndpoint` field.
3939+ #
4040+ # @api public
4141+ # @return [String, nil] labeler service endpoint URL
4242+1143 def labeler_endpoint
1244 @labeler_endpoint ||= get_service('atproto_labeler', 'AtprotoLabeler')&.endpoint
1345 end
4646+4747+ # Returns the hostname of the PDS service, if present.
4848+ #
4949+ # @api public
5050+ # @return [String, nil] hostname of the PDS endpoint URL
5151+5252+ def pds_host
5353+ pds_endpoint&.then { |x| URI(x).host }
5454+ end
5555+5656+ # Returns the hostname of the labeler service, if present.
5757+ #
5858+ # @api public
5959+ # @return [String, nil] hostname of the labeler endpoint URL
6060+6161+ def labeler_host
6262+ labeler_endpoint&.then { |x| URI(x).host }
6363+ end
6464+6565+ alias labeller_endpoint labeler_endpoint
6666+ alias labeller_host labeler_host
1467 end
1568end
+1-1
lib/didkit/version.rb
···11# frozen_string_literal: true
2233module DIDKit
44- VERSION = "0.2.1"
44+ VERSION = "0.3.1"
55end
+238
spec/did_spec.rb
···11+describe DIDKit::DID do
22+ subject { described_class }
33+44+ let(:plc_did) { 'did:plc:vc7f4oafdgxsihk4cry2xpze' }
55+ let(:web_did) { 'did:web:taylorswift.com' }
66+77+ describe '#initialize' do
88+ context 'with a valid did:plc' do
99+ it 'should return an initialized DID object' do
1010+ did = subject.new(plc_did)
1111+1212+ did.should be_a(DIDKit::DID)
1313+ did.type.should == :plc
1414+ did.did.should be_a(String)
1515+ did.did.should == plc_did
1616+ did.resolved_by.should be_nil
1717+ end
1818+ end
1919+2020+ context 'with a valid did:web' do
2121+ it 'should return an initialized DID object' do
2222+ did = subject.new(web_did)
2323+2424+ did.should be_a(DIDKit::DID)
2525+ did.type.should == :web
2626+ did.did.should be_a(String)
2727+ did.did.should == web_did
2828+ did.resolved_by.should be_nil
2929+ end
3030+ end
3131+3232+ context 'with another DID object' do
3333+ it 'should create a copy of the DID' do
3434+ other = subject.new(plc_did)
3535+ did = subject.new(other)
3636+3737+ did.did.should == plc_did
3838+ did.type.should == :plc
3939+ did.equal?(other).should == false
4040+ end
4141+ end
4242+4343+ context 'with a string that is not a DID' do
4444+ it 'should raise an error' do
4545+ expect {
4646+ subject.new('not-a-did')
4747+ }.to raise_error(DIDKit::DIDError)
4848+ end
4949+ end
5050+5151+ context 'when an unrecognized did: type' do
5252+ it 'should raise an error' do
5353+ expect {
5454+ subject.new('did:example:123')
5555+ }.to raise_error(DIDKit::DIDError)
5656+ end
5757+ end
5858+ end
5959+6060+ describe '#web_domain' do
6161+ context 'for a did:web' do
6262+ it 'should return the domain part' do
6363+ did = subject.new('did:web:site.example.com')
6464+6565+ did.web_domain.should == 'site.example.com'
6666+ end
6767+ end
6868+6969+ context 'for a did:plc' do
7070+ it 'should return nil' do
7171+ did = subject.new('did:plc:yk4dd2qkboz2yv6tpubpc6co')
7272+7373+ did.web_domain.should be_nil
7474+ end
7575+ end
7676+ end
7777+7878+ describe '#==' do
7979+ let(:did_string) { 'did:plc:vc7f4oafdgxsihk4cry2xpze' }
8080+ let(:other_string) { 'did:plc:oio4hkxaop4ao4wz2pp3f4cr' }
8181+8282+ let(:did) { subject.new(did_string) }
8383+ let(:other) { subject.new(other_string) }
8484+8585+ context 'given a DID string' do
8686+ it 'should compare its string value to the other DID' do
8787+ did.should == did_string
8888+ did.should_not == other_string
8989+ end
9090+ end
9191+9292+ context 'given another DID object' do
9393+ it "should compare its string value to the other DID's string value" do
9494+ copy = subject.new(did_string)
9595+9696+ did.should == copy
9797+ did.should_not == other
9898+ end
9999+ end
100100+101101+ context 'given something that is not a DID' do
102102+ it 'should return false' do
103103+ did.should_not == :didplc
104104+ did.should_not == [did_string]
105105+ end
106106+ end
107107+ end
108108+109109+ describe '#to_s' do
110110+ it "should return the DID's string value" do
111111+ did = subject.new(plc_did)
112112+113113+ did.to_s.should be_a(String)
114114+ did.to_s.should == plc_did
115115+ end
116116+ end
117117+118118+ describe 'account status' do
119119+ let(:document) { stub(:pds_endpoint => 'https://pds.ruby.space') }
120120+ let(:did) { subject.new(plc_did) }
121121+122122+ before do
123123+ did.stubs(:document).returns(document)
124124+125125+ stub_request(:get, 'https://pds.ruby.space/xrpc/com.atproto.sync.getRepoStatus')
126126+ .with(query: { did: plc_did })
127127+ .to_return(http_response) if defined?(http_response)
128128+ end
129129+130130+ context 'when repo is active' do
131131+ let(:http_response) {
132132+ { body: { active: true }.to_json, headers: { 'Content-Type' => 'application/json' }}
133133+ }
134134+135135+ it 'should report active account state' do
136136+ did.account_status.should == :active
137137+ did.account_active?.should == true
138138+ did.account_exists?.should == true
139139+ end
140140+ end
141141+142142+ context 'when repo is inactive' do
143143+ let(:http_response) {
144144+ { body: { active: false, status: 'takendown' }.to_json, headers: { 'Content-Type' => 'application/json' }}
145145+ }
146146+147147+ it 'should report an inactive existing account' do
148148+ did.account_status.should == :takendown
149149+ did.account_active?.should == false
150150+ did.account_exists?.should == true
151151+ end
152152+ end
153153+154154+ context 'when repo is not found' do
155155+ let(:http_response) {
156156+ { status: 400, body: { error: 'RepoNotFound' }.to_json, headers: { 'Content-Type' => 'application/json' }}
157157+ }
158158+159159+ it 'should return nil status and report the account as missing' do
160160+ did.account_status.should be_nil
161161+ did.account_active?.should == false
162162+ did.account_exists?.should == false
163163+ end
164164+ end
165165+166166+ context 'when the document has no pds endpoint' do
167167+ before do
168168+ did.stubs(:document).returns(stub(:pds_endpoint => nil))
169169+ end
170170+171171+ it 'should return nil status and report the account as missing' do
172172+ did.account_status.should be_nil
173173+ did.account_active?.should == false
174174+ did.account_exists?.should == false
175175+ end
176176+ end
177177+178178+ context 'when active field is not set' do
179179+ let(:http_response) {
180180+ { body: { active: nil, status: 'unknown' }.to_json, headers: { 'Content-Type' => 'application/json' }}
181181+ }
182182+183183+ it 'should raise APIError' do
184184+ expect { did.account_status }.to raise_error(DIDKit::APIError)
185185+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
186186+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
187187+ end
188188+ end
189189+190190+ context 'when active is false but status is not set' do
191191+ let(:http_response) {
192192+ { body: { active: false, status: nil }.to_json, headers: { 'Content-Type' => 'application/json' }}
193193+ }
194194+195195+ it 'should raise APIError' do
196196+ expect { did.account_status }.to raise_error(DIDKit::APIError)
197197+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
198198+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
199199+ end
200200+ end
201201+202202+ context 'when an error different than RepoNotFound is returned' do
203203+ let(:http_response) {
204204+ { status: 400, body: { error: 'UserIsJerry' }.to_json, headers: { 'Content-Type' => 'application/json' }}
205205+ }
206206+207207+ it 'should raise APIError' do
208208+ expect { did.account_status }.to raise_error(DIDKit::APIError)
209209+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
210210+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
211211+ end
212212+ end
213213+214214+ context 'when the response is not application/json' do
215215+ let(:http_response) {
216216+ { status: 400, body: 'error', headers: { 'Content-Type' => 'text/html' }}
217217+ }
218218+219219+ it 'should raise APIError' do
220220+ expect { did.account_status }.to raise_error(DIDKit::APIError)
221221+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
222222+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
223223+ end
224224+ end
225225+226226+ context 'when the response is not 200 or 400' do
227227+ let(:http_response) {
228228+ { status: 500, body: { error: 'RepoNotFound' }.to_json, headers: { 'Content-Type' => 'application/json' }}
229229+ }
230230+231231+ it 'should raise APIError' do
232232+ expect { did.account_status }.to raise_error(DIDKit::APIError)
233233+ expect { did.account_active? }.to raise_error(DIDKit::APIError)
234234+ expect { did.account_exists? }.to raise_error(DIDKit::APIError)
235235+ end
236236+ end
237237+ end
238238+end
+2-2
spec/didkit_spec.rb
···11# frozen_string_literal: true
2233-RSpec.describe Didkit do
33+RSpec.describe DIDKit do
44 it "has a version number" do
55- expect(Didkit::VERSION).not_to be nil
55+ expect(DIDKit::VERSION).not_to be nil
66 end
77end
···11+describe DIDKit::Document do
22+ subject { described_class }
33+44+ let(:did) { DID.new('did:plc:yk4dd2qkboz2yv6tpubpc6co') }
55+ let(:base_json) { load_did_json('dholms.json') }
66+77+ describe '#initialize' do
88+ context 'with valid input' do
99+ let(:json) { base_json }
1010+1111+ it 'should return a Document object' do
1212+ doc = subject.new(did, json)
1313+1414+ doc.should be_a(DIDKit::Document)
1515+ doc.did.should == did
1616+ doc.json.should == json
1717+ end
1818+1919+ it 'should parse services from the JSON' do
2020+ doc = subject.new(did, json)
2121+2222+ doc.services.should be_an(Array)
2323+ doc.services.length.should == 1
2424+2525+ doc.services[0].should be_a(DIDKit::ServiceRecord)
2626+ doc.services[0].key.should == 'atproto_pds'
2727+ doc.services[0].type.should == 'AtprotoPersonalDataServer'
2828+ doc.services[0].endpoint.should == 'https://pds.dholms.xyz'
2929+ end
3030+3131+ it 'should parse handles from the JSON' do
3232+ doc = subject.new(did, json)
3333+3434+ doc.handles.should == ['dholms.xyz']
3535+ end
3636+ end
3737+3838+ context 'when id is missing' do
3939+ let(:json) { base_json.dup.tap { |h| h.delete('id') }}
4040+4141+ it 'should raise a format error' do
4242+ expect {
4343+ subject.new(did, json)
4444+ }.to raise_error(DIDKit::FormatError)
4545+ end
4646+ end
4747+4848+ context 'when id is not a string' do
4949+ let(:json) { base_json.merge('id' => 123) }
5050+5151+ it 'should raise a format error' do
5252+ expect {
5353+ subject.new(did, json)
5454+ }.to raise_error(DIDKit::FormatError)
5555+ end
5656+ end
5757+5858+ context 'when id does not match the DID' do
5959+ let(:json) { base_json.merge('id' => 'did:plc:notmatching') }
6060+6161+ it 'should raise a format error' do
6262+ expect {
6363+ subject.new(did, json)
6464+ }.to raise_error(DIDKit::FormatError)
6565+ end
6666+ end
6767+6868+ context 'when alsoKnownAs is not an array' do
6969+ let(:json) { base_json.merge('alsoKnownAs' => 'at://dholms.xyz') }
7070+7171+ it 'should raise an AtHandles format error' do
7272+ expect {
7373+ subject.new(did, json)
7474+ }.to raise_error(DIDKit::FormatError)
7575+ end
7676+ end
7777+7878+ context 'when alsoKnownAs elements are not strings' do
7979+ let(:json) { base_json.merge('alsoKnownAs' => [666]) }
8080+8181+ it 'should raise an AtHandles format error' do
8282+ expect {
8383+ subject.new(did, json)
8484+ }.to raise_error(DIDKit::FormatError)
8585+ end
8686+ end
8787+8888+ context 'when alsoKnownAs contains multiple handles' do
8989+ let(:json) {
9090+ base_json.merge('alsoKnownAs' => [
9191+ 'at://dholms.xyz',
9292+ 'https://example.com',
9393+ 'at://other.handle'
9494+ ])
9595+ }
9696+9797+ it 'should pick those starting with at:// and remove the prefixes' do
9898+ doc = subject.new(did, json)
9999+ doc.handles.should == ['dholms.xyz', 'other.handle']
100100+ end
101101+ end
102102+103103+ context 'when service is not an array' do
104104+ let(:json) { base_json.merge('service' => 'not-an-array') }
105105+106106+ it 'should raise a format error' do
107107+ expect {
108108+ subject.new(did, json)
109109+ }.to raise_error(DIDKit::FormatError)
110110+ end
111111+ end
112112+113113+ context 'when service entries are not hashes' do
114114+ let(:json) { base_json.merge('service' => ['invalid']) }
115115+116116+ it 'should raise a format error' do
117117+ expect {
118118+ subject.new(did, json)
119119+ }.to raise_error(DIDKit::FormatError)
120120+ end
121121+ end
122122+123123+ context 'when service entries are partially valid' do
124124+ let(:services) {
125125+ [
126126+ { 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' },
127127+ { 'id' => 'not_a_hash', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' },
128128+ { 'id' => '#wrong_type', 'type' => 123, 'serviceEndpoint' => 'https://pds.dholms.xyz' },
129129+ { 'id' => '#wrong_endpoint', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 123 },
130130+ { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' }
131131+ ]
132132+ }
133133+134134+ let(:json) { base_json.merge('service' => services) }
135135+136136+ it 'should only keep the valid records' do
137137+ doc = subject.new(did, json)
138138+139139+ doc.services.length.should == 2
140140+ doc.services.map(&:key).should == ['atproto_pds', 'lycan']
141141+ doc.services.map(&:type).should == ['AtprotoPersonalDataServer', 'LycanService']
142142+ doc.services.map(&:endpoint).should == ['https://pds.dholms.xyz', 'https://lycan.feeds.blue']
143143+ end
144144+ end
145145+ end
146146+147147+ describe 'service helpers' do
148148+ let(:service_json) {
149149+ base_json.merge('service' => [
150150+ { 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' },
151151+ { 'id' => '#atproto_labeler', 'type' => 'AtprotoLabeler', 'serviceEndpoint' => 'https://labels.dholms.xyz' },
152152+ { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' }
153153+ ])
154154+ }
155155+156156+ describe '#pds_endpoint' do
157157+ it 'should return the endpoint of #atproto_pds' do
158158+ doc = subject.new(did, service_json)
159159+ doc.pds_endpoint.should == 'https://pds.dholms.xyz'
160160+ end
161161+ end
162162+163163+ describe '#pds_host' do
164164+ it 'should return the host part of #atproto_pds endpoint' do
165165+ doc = subject.new(did, service_json)
166166+ doc.pds_host.should == 'pds.dholms.xyz'
167167+ end
168168+ end
169169+170170+ describe '#labeler_endpoint' do
171171+ it 'should return the endpoint of #atproto_labeler' do
172172+ doc = subject.new(did, service_json)
173173+ doc.labeler_endpoint.should == 'https://labels.dholms.xyz'
174174+ end
175175+ end
176176+177177+ describe '#labeler_host' do
178178+ it 'should return the host part of #atproto_labeler endpoint' do
179179+ doc = subject.new(did, service_json)
180180+ doc.labeler_host.should == 'labels.dholms.xyz'
181181+ end
182182+ end
183183+184184+ describe '#get_service' do
185185+ it 'should fetch a service by key and type' do
186186+ doc = subject.new(did, service_json)
187187+188188+ lycan = doc.get_service('lycan', 'LycanService')
189189+ lycan.should_not be_nil
190190+ lycan.endpoint.should == 'https://lycan.feeds.blue'
191191+ end
192192+193193+ it 'should return nil if none of the services match' do
194194+ doc = subject.new(did, service_json)
195195+196196+ result = doc.get_service('lycan', 'AtprotoLabeler')
197197+ result.should be_nil
198198+199199+ result = doc.get_service('atproto_pds', 'PDS')
200200+ result.should be_nil
201201+202202+ result = doc.get_service('unknown', 'Test')
203203+ result.should be_nil
204204+ end
205205+ end
206206+207207+ it 'should expose the "labeller" aliases for endpoint and host' do
208208+ doc = subject.new(did, service_json)
209209+210210+ doc.labeller_endpoint.should == 'https://labels.dholms.xyz'
211211+ doc.labeller_host.should == 'labels.dholms.xyz'
212212+ end
213213+214214+ describe 'if there is no matching service' do
215215+ let(:service_json) {
216216+ base_json.merge('service' => [
217217+ { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' }
218218+ ])
219219+ }
220220+221221+ it 'should return nil from the relevant methods' do
222222+ doc = subject.new(did, service_json)
223223+224224+ doc.pds_endpoint.should be_nil
225225+ doc.pds_host.should be_nil
226226+ doc.labeller_endpoint.should be_nil
227227+ doc.labeller_host.should be_nil
228228+ doc.labeler_endpoint.should be_nil
229229+ doc.labeler_host.should be_nil
230230+ end
231231+ end
232232+ end
233233+end
+358
spec/plc_operation_spec.rb
···11+require 'time'
22+33+describe DIDKit::PLCOperation do
44+ subject { described_class }
55+66+ let(:base_json) { load_did_json('bnewbold_log.json').last }
77+88+ describe '#initialize' do
99+ context 'with a valid plc operation' do
1010+ let(:json) { base_json }
1111+1212+ it 'should return a PLCOperation with parsed data' do
1313+ op = subject.new(json)
1414+1515+ op.json.should == json
1616+ op.type.should == :plc_operation
1717+ op.did.should == 'did:plc:44ybard66vv44zksje25o7dz'
1818+ op.cid.should == 'bafyreiaoaelqu32ngmqd2mt3v3zvek7k34cvo7lvmk3kseuuaag5eptg5m'
1919+ op.created_at.should be_a(Time)
2020+ op.created_at.should == Time.parse("2025-06-06T00:34:40.824Z")
2121+ op.handles.should == ['bnewbold.net']
2222+ op.services.map(&:key).should == ['atproto_pds']
2323+ end
2424+ end
2525+2626+ context 'when argument is not a hash' do
2727+ let(:json) { [base_json] }
2828+2929+ it 'should raise a format error' do
3030+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
3131+ end
3232+ end
3333+3434+ context 'when did is missing' do
3535+ let(:json) { base_json.tap { |h| h.delete('did') }}
3636+3737+ it 'should raise a format error' do
3838+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
3939+ end
4040+ end
4141+4242+ context 'when did is not a string' do
4343+ let(:json) { base_json.merge('did' => 123) }
4444+4545+ it 'should raise a format error' do
4646+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
4747+ end
4848+ end
4949+5050+ context "when did doesn't start with did:" do
5151+ let(:json) { base_json.merge('did' => 'foobar') }
5252+5353+ it 'should raise a format error' do
5454+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
5555+ end
5656+ end
5757+5858+ context 'when cid is missing' do
5959+ let(:json) { base_json.tap { |h| h.delete('cid') }}
6060+6161+ it 'should raise a format error' do
6262+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
6363+ end
6464+ end
6565+6666+ context 'when cid is not a string' do
6767+ let(:json) { base_json.merge('cid' => 700) }
6868+6969+ it 'should raise a format error' do
7070+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
7171+ end
7272+ end
7373+7474+ context 'when createdAt is missing' do
7575+ let(:json) { base_json.tap { |h| h.delete('createdAt') }}
7676+7777+ it 'should raise a format error' do
7878+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
7979+ end
8080+ end
8181+8282+ context 'when createdAt is invalid' do
8383+ let(:json) { base_json.merge('createdAt' => 123) }
8484+8585+ it 'should raise a format error' do
8686+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
8787+ end
8888+ end
8989+9090+ context 'when operation block is missing' do
9191+ let(:json) { base_json.tap { |h| h.delete('operation') }}
9292+9393+ it 'should raise a format error' do
9494+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
9595+ end
9696+ end
9797+9898+ context 'when operation block is not a hash' do
9999+ let(:json) { base_json.merge('operation' => 'invalid') }
100100+101101+ it 'should raise a format error' do
102102+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
103103+ end
104104+ end
105105+106106+ context 'when operation type is missing' do
107107+ let(:json) { base_json.tap { |h| h['operation'].delete('type') }}
108108+109109+ it 'should raise a format error' do
110110+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
111111+ end
112112+ end
113113+114114+ context 'when operation type is not plc_operation' do
115115+ let(:json) { base_json.tap { |h| h['operation']['type'] = 'other' }}
116116+117117+ it 'should not raise an error' do
118118+ expect { subject.new(json) }.not_to raise_error
119119+ end
120120+121121+ it 'should return the operation type' do
122122+ op = subject.new(json)
123123+ op.type.should == :other
124124+ end
125125+126126+ it 'should not try to parse services' do
127127+ json['services'] = nil
128128+129129+ expect { subject.new(json) }.not_to raise_error
130130+ end
131131+132132+ it 'should return nil from services' do
133133+ op = subject.new(json)
134134+ op.services.should be_nil
135135+ end
136136+137137+ it 'should not try to parse handles' do
138138+ json['alsoKnownAs'] = nil
139139+140140+ expect { subject.new(json) }.not_to raise_error
141141+ end
142142+143143+ it 'should return nil from handles' do
144144+ op = subject.new(json)
145145+ op.handles.should be_nil
146146+ end
147147+ end
148148+149149+ context 'when alsoKnownAs is not an array' do
150150+ let(:json) { base_json.tap { |h| h['operation']['alsoKnownAs'] = 'at://dholms.xyz' }}
151151+152152+ it 'should raise an AtHandles format error' do
153153+ expect {
154154+ subject.new(json)
155155+ }.to raise_error(DIDKit::FormatError)
156156+ end
157157+ end
158158+159159+ context 'when alsoKnownAs elements are not strings' do
160160+ let(:json) { base_json.tap { |h| h['operation']['alsoKnownAs'] = [666] }}
161161+162162+ it 'should raise an AtHandles format error' do
163163+ expect {
164164+ subject.new(json)
165165+ }.to raise_error(DIDKit::FormatError)
166166+ end
167167+ end
168168+169169+ context 'when alsoKnownAs contains multiple handles' do
170170+ let(:json) {
171171+ base_json.tap { |h|
172172+ h['operation']['alsoKnownAs'] = [
173173+ 'at://dholms.xyz',
174174+ 'https://example.com',
175175+ 'at://other.handle'
176176+ ]
177177+ }
178178+ }
179179+180180+ it 'should pick those starting with at:// and remove the prefixes' do
181181+ op = subject.new(json)
182182+ op.handles.should == ['dholms.xyz', 'other.handle']
183183+ end
184184+ end
185185+186186+ context 'when services are missing' do
187187+ let(:json) { base_json.tap { |h| h['operation'].delete('services') }}
188188+189189+ it 'should raise a format error' do
190190+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
191191+ end
192192+ end
193193+194194+ context 'when services entry is not a hash' do
195195+ let(:json) {
196196+ base_json.tap { |h|
197197+ h['operation']['services'] = [
198198+ {
199199+ "id": "#atproto_pds",
200200+ "type": "AtprotoPersonalDataServer",
201201+ "serviceEndpoint": "https://pds.dholms.xyz"
202202+ }
203203+ ]
204204+ }
205205+ }
206206+207207+ it 'should raise a format error' do
208208+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
209209+ end
210210+ end
211211+212212+ context 'when a service entry is missing fields' do
213213+ let(:json) {
214214+ base_json.tap { |h|
215215+ h['operation']['services'] = {
216216+ "atproto_pds" => {
217217+ "endpoint" => "https://pds.dholms.xyz"
218218+ },
219219+ "atproto_labeler" => {
220220+ "type" => "AtprotoLabeler",
221221+ "endpoint" => "https://labeler.example.com"
222222+ }
223223+ }
224224+ }
225225+ }
226226+227227+ it 'should raise a format error' do
228228+ expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
229229+ end
230230+ end
231231+232232+ context 'when services are valid' do
233233+ let(:json) {
234234+ base_json.tap { |h|
235235+ h['operation']['services'] = {
236236+ "atproto_pds" => {
237237+ "type" => "AtprotoPersonalDataServer",
238238+ "endpoint" => "https://pds.dholms.xyz"
239239+ },
240240+ "atproto_labeler" => {
241241+ "type" => "AtprotoLabeler",
242242+ "endpoint" => "https://labeler.example.com"
243243+ },
244244+ "custom_service" => {
245245+ "type" => "OtherService",
246246+ "endpoint" => "https://custom.example.com"
247247+ }
248248+ }
249249+ }
250250+ }
251251+252252+ it 'should parse services into ServiceRecords' do
253253+ op = subject.new(json)
254254+255255+ op.services.length.should == 3
256256+ op.services.each { |s| s.should be_a(DIDKit::ServiceRecord) }
257257+258258+ pds, labeller, custom = op.services
259259+260260+ pds.type.should == 'AtprotoPersonalDataServer'
261261+ pds.endpoint.should == 'https://pds.dholms.xyz'
262262+263263+ labeller.type.should == 'AtprotoLabeler'
264264+ labeller.endpoint.should == 'https://labeler.example.com'
265265+266266+ custom.type.should == 'OtherService'
267267+ custom.endpoint.should == 'https://custom.example.com'
268268+ end
269269+270270+ it 'should allow fetching services by key + type' do
271271+ op = subject.new(json)
272272+273273+ custom = op.get_service('custom_service', 'OtherService')
274274+ custom.should be_a(DIDKit::ServiceRecord)
275275+ custom.endpoint.should == 'https://custom.example.com'
276276+ end
277277+278278+ describe '#pds_endpoint' do
279279+ it 'should return the endpoint of #atproto_pds' do
280280+ op = subject.new(json)
281281+ op.pds_endpoint.should == 'https://pds.dholms.xyz'
282282+ end
283283+ end
284284+285285+ describe '#pds_host' do
286286+ it 'should return the host part of #atproto_pds endpoint' do
287287+ op = subject.new(json)
288288+ op.pds_host.should == 'pds.dholms.xyz'
289289+ end
290290+ end
291291+292292+ describe '#labeler_endpoint' do
293293+ it 'should return the endpoint of #atproto_labeler' do
294294+ op = subject.new(json)
295295+ op.labeler_endpoint.should == 'https://labeler.example.com'
296296+ end
297297+ end
298298+299299+ describe '#labeler_host' do
300300+ it 'should return the host part of #atproto_labeler endpoint' do
301301+ op = subject.new(json)
302302+ op.labeler_host.should == 'labeler.example.com'
303303+ end
304304+ end
305305+306306+ it 'should expose the "labeller" aliases for endpoint and host' do
307307+ op = subject.new(json)
308308+309309+ op.labeller_endpoint.should == 'https://labeler.example.com'
310310+ op.labeller_host.should == 'labeler.example.com'
311311+ end
312312+ end
313313+314314+ context 'when services are valid but the specific ones are missing' do
315315+ let(:json) {
316316+ base_json.tap { |h|
317317+ h['operation']['services'] = {
318318+ "custom_service" => {
319319+ "type" => "CustomService",
320320+ "endpoint" => "https://custom.example.com"
321321+ }
322322+ }
323323+ }
324324+ }
325325+326326+ it 'should parse service records' do
327327+ op = subject.new(json)
328328+ op.services.length.should == 1
329329+ end
330330+331331+ describe '#get_service' do
332332+ it 'should return nil' do
333333+ op = subject.new(json)
334334+ other = op.get_service('other_service', 'OtherService')
335335+ other.should be_nil
336336+ end
337337+ end
338338+339339+ describe '#pds_endpoint' do
340340+ it 'should return nil' do
341341+ op = subject.new(json)
342342+ op.pds_endpoint.should be_nil
343343+ op.pds_host.should be_nil
344344+ end
345345+ end
346346+347347+ describe '#labeler_endpoint' do
348348+ it 'should return nil' do
349349+ op = subject.new(json)
350350+ op.labeler_endpoint.should be_nil
351351+ op.labeller_endpoint.should be_nil
352352+ op.labeler_host.should be_nil
353353+ op.labeller_host.should be_nil
354354+ end
355355+ end
356356+ end
357357+ end
358358+end
+179
spec/resolver_spec.rb
···11+describe DIDKit::Resolver do
22+ let(:sample_did) { 'did:plc:qhfo22pezo44fa3243z2h4ny' }
33+44+ describe '#resolve_handle' do
55+ context 'when handle resolves via HTTP' do
66+ before do
77+ Resolv::DNS.stubs(:open).returns([])
88+ end
99+1010+ let(:handle) { 'barackobama.bsky.social' }
1111+1212+ it 'should return a matching DID' do
1313+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
1414+ .to_return(body: sample_did)
1515+1616+ result = subject.resolve_handle(handle)
1717+1818+ result.should_not be_nil
1919+ result.should be_a(DID)
2020+ result.to_s.should == sample_did
2121+ result.resolved_by.should == :http
2222+ end
2323+2424+ it 'should check DNS first' do
2525+ Resolv::DNS.expects(:open).returns([])
2626+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
2727+ .to_return(body: sample_did)
2828+2929+ result = subject.resolve_handle(handle)
3030+ end
3131+3232+ context 'when HTTP returns invalid text' do
3333+ it 'should return nil' do
3434+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
3535+ .to_return(body: "Welcome to nginx!")
3636+3737+ result = subject.resolve_handle(handle)
3838+ result.should be_nil
3939+ end
4040+ end
4141+4242+ context 'when HTTP returns bad response' do
4343+ it 'should return nil' do
4444+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
4545+ .to_return(status: 400, body: sample_did)
4646+4747+ result = subject.resolve_handle(handle)
4848+ result.should be_nil
4949+ end
5050+ end
5151+5252+ context 'when HTTP throws an exception' do
5353+ it 'should catch it and return nil' do
5454+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
5555+ .to_raise(Errno::ETIMEDOUT)
5656+5757+ result = 0
5858+5959+ expect {
6060+ result = subject.resolve_handle(handle)
6161+ }.to_not raise_error
6262+6363+ result.should be_nil
6464+ end
6565+ end
6666+6767+ context 'when HTTP response has a trailing newline' do
6868+ it 'should accept it' do
6969+ stub_request(:get, "https://#{handle}/.well-known/atproto-did")
7070+ .to_return(body: sample_did + "\n")
7171+7272+ result = subject.resolve_handle(handle)
7373+7474+ result.should_not be_nil
7575+ result.should be_a(DID)
7676+ result.to_s.should == sample_did
7777+ end
7878+ end
7979+ end
8080+8181+ context 'when handle has a leading @' do
8282+ let(:handle) { '@pfrazee.com' }
8383+8484+ before do
8585+ Resolv::DNS.stubs(:open).returns([])
8686+ end
8787+8888+ it 'should also return a matching DID' do
8989+ stub_request(:get, "https://pfrazee.com/.well-known/atproto-did")
9090+ .to_return(body: sample_did)
9191+9292+ result = subject.resolve_handle(handle)
9393+9494+ result.should_not be_nil
9595+ result.should be_a(DID)
9696+ result.to_s.should == sample_did
9797+ result.resolved_by.should == :http
9898+ end
9999+ end
100100+101101+ context 'when handle has a reserved TLD' do
102102+ let(:handle) { 'example.test' }
103103+104104+ it 'should return nil' do
105105+ subject.resolve_handle(handle).should be_nil
106106+ end
107107+ end
108108+109109+ context 'when a DID string is passed' do
110110+ let(:handle) { BSKY_APP_DID }
111111+112112+ it 'should return that DID' do
113113+ result = subject.resolve_handle(handle)
114114+115115+ result.should be_a(DID)
116116+ result.to_s.should == BSKY_APP_DID
117117+ end
118118+ end
119119+120120+ context 'when a DID object is passed' do
121121+ let(:handle) { DID.new(BSKY_APP_DID) }
122122+123123+ it 'should return a new DID object with that DID' do
124124+ result = subject.resolve_handle(handle)
125125+126126+ result.should be_a(DID)
127127+ result.to_s.should == BSKY_APP_DID
128128+ result.equal?(handle).should == false
129129+ end
130130+ end
131131+ end
132132+133133+ describe '#resolve_did' do
134134+ context 'when passed a did:plc string' do
135135+ let(:did) { 'did:plc:yk4dd2qkboz2yv6tpubpc6co' }
136136+137137+ it 'should return a parsed DID document object' do
138138+ stub_request(:get, "https://plc.directory/#{did}")
139139+ .to_return(body: load_did_file('dholms.json'), headers: { 'Content-Type': 'application/did+ld+json; charset=utf-8' })
140140+141141+ result = subject.resolve_did(did)
142142+ result.should be_a(DIDKit::Document)
143143+ result.handles.should == ['dholms.xyz']
144144+ result.pds_endpoint.should == 'https://pds.dholms.xyz'
145145+ end
146146+147147+ it 'should require a valid content type' do
148148+ stub_request(:get, "https://plc.directory/#{did}")
149149+ .to_return(body: load_did_file('dholms.json'), headers: { 'Content-Type': 'text/plain' })
150150+151151+ expect { subject.resolve_did(did) }.to raise_error(DIDKit::APIError)
152152+ end
153153+ end
154154+155155+ context 'when passed a did:web string' do
156156+ let(:did) { 'did:web:witchcraft.systems' }
157157+158158+ it 'should return a parsed DID document object' do
159159+ stub_request(:get, "https://witchcraft.systems/.well-known/did.json")
160160+ .to_return(body: load_did_file('witchcraft.json'), headers: { 'Content-Type': 'application/did+ld+json; charset=utf-8' })
161161+162162+ result = subject.resolve_did(did)
163163+ result.should be_a(DIDKit::Document)
164164+ result.handles.should == ['witchcraft.systems']
165165+ result.pds_endpoint.should == 'https://pds.witchcraft.systems'
166166+ end
167167+168168+ it 'should NOT require a valid content type' do
169169+ stub_request(:get, "https://witchcraft.systems/.well-known/did.json")
170170+ .to_return(body: load_did_file('witchcraft.json'), headers: { 'Content-Type': 'text/plain' })
171171+172172+ result = subject.resolve_did(did)
173173+ result.should be_a(DIDKit::Document)
174174+ result.handles.should == ['witchcraft.systems']
175175+ result.pds_endpoint.should == 'https://pds.witchcraft.systems'
176176+ end
177177+ end
178178+ end
179179+end
+44-5
spec/spec_helper.rb
···11# frozen_string_literal: true
2233-require "didkit"
33+require 'simplecov'
44+55+SimpleCov.start do
66+ enable_coverage :branch
77+ add_filter "/spec/"
88+end
99+1010+require 'didkit'
1111+require 'json'
1212+require 'webmock/rspec'
413514RSpec.configure do |config|
615 # Enable flags like --only-failures and --next-failure
716 config.example_status_persistence_file_path = ".rspec_status"
81799- # Disable RSpec exposing methods globally on `Module` and `main`
1010- config.disable_monkey_patching!
1818+ config.expect_with :rspec do |c|
1919+ c.syntax = [:should, :expect]
2020+ end
2121+2222+ config.mock_with :mocha
2323+end
2424+2525+module SimpleCov
2626+ module Formatter
2727+ class HTMLFormatter
2828+ def format(result)
2929+ # silence the stdout summary, just save the html files
3030+ unless @inline_assets
3131+ Dir[File.join(@public_assets_dir, "*")].each do |path|
3232+ FileUtils.cp_r(path, asset_output_path, remove_destination: true)
3333+ end
3434+ end
11351212- config.expect_with :rspec do |c|
1313- c.syntax = :expect
3636+ File.open(File.join(output_path, "index.html"), "wb") do |file|
3737+ file.puts template("layout").result(binding)
3838+ end
3939+ end
4040+ end
1441 end
1542end
4343+4444+BSKY_APP_DID = 'did:plc:z72i7hdynmk6r22z27h6tvur'
4545+4646+WebMock.enable!
4747+4848+def load_did_file(name)
4949+ File.read(File.join(__dir__, 'dids', name))
5050+end
5151+5252+def load_did_json(name)
5353+ JSON.parse(load_did_file(name))
5454+end