Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

monovm-whois-java

Domain WHOIS lookups and availability checking for Java. Queries registries over port 43 or RDAP, ships a server list covering almost every extension, and decides availability with a rule chain you can inspect, extend, or replace.

Zero runtime dependencies. Java 17+. A proper JPMS module.

This is the Java sibling of monovm/whois-php; its verdicts are pinned to that package's behaviour by recorded parity tests.


Installation

Maven:

<dependency>
  <groupId>com.monovm</groupId>
  <artifactId>monovm-whois-java</artifactId>
  <version>1.0.0</version>
</dependency>

Gradle:

implementation("com.monovm:monovm-whois-java:1.0.0")

Module path:

requires com.monovm.whois;

Quick start

try (WhoisClient client = WhoisClient.create()) {

    WhoisResult result = client.lookup("monovm.com");

    result.availability();   // UNAVAILABLE
    result.isAvailable();    // false
    result.message();        // the registry's answer, verbatim
}

A bare name is checked across the popular extensions:

Map<String, DomainAvailability> statuses = client.check("monovm");
// {monovm.com=unavailable, monovm.net=unavailable, monovm.org=unavailable, monovm.info=unavailable}

Several names at once, looked up in parallel:

List<WhoisResult> results = client.lookupAll(List.of("monovm.com", "example.dev", "bbc.co.uk"));

One client per application, shared: it is immutable and thread safe. Parallel lookups run on one lazily created pool of daemon threads, which close() releases along with any transport the client created itself.

Why did it say that?

Every verdict carries the rule that reached it and the evidence:

WhoisResult result = client.lookup("monovm.com");

result.detection().ifPresent(detection -> {
    detection.decidingRule();   // registration-indicators
    detection.reason();         // 6 registration fields present
});

System.out.println(client.describe(result).orElseThrow().toDisplayString());
verdict: NOT_AVAILABLE (unavailability-indicators: unavailability pattern /status:\s*client/i matched)
response: 1043 chars
  registry-available-phrase -> Abstain (no matching signal)
  blank-response -> Abstain (no matching signal)
  server-error -> Abstain (no matching signal)
  unavailability-indicators -> Registered (unavailability pattern /status:\s*client/i matched)
  ...

Failures are values

An unknown extension or an unreachable server is a result, not an exception, so one broken registry cannot abort a check of a hundred names:

client.lookup("example.nosuchtld").availability();   // INVALID
client.lookup("monovm.com").availability();          // ERROR if the server is unreachable

Use lookupOrThrow where a failure should propagate — it throws UnsupportedTldException, WhoisServerUnavailableException or WhoisTransportException according to what actually went wrong.


Coming from whois-php

Both facades from the PHP package exist, with the same names and semantics:

Checker.whois("monovm.com");                                   // {monovm.com=unavailable}
Checker.whois("monovm", "google.com", "bing");                 // one entry per resolved domain
Checker.whois(List.of("monovm"), List.of(Tld.of("info")));     // custom popularTLDs

WhoisHandler handler = WhoisHandler.whois("monovm.com");
handler.isAvailable();
handler.isValid();
handler.whoisMessage();
handler.tld();
handler.sld();
handler.availabilityDetails();   // the getAvailabilityDetails() field names

DomainAvailability.code() returns the same lowercase strings PHP returns (available, unavailable, premium, invalid, error), so output from the two libraries can be diffed directly.

Deliberate differences

whois-php whois-java
Empty response reported available reported error — see below
WHOIS text htmlentities() + nl2br() applied returned exactly as received
www.monovm.com read as www under .monovm.com, so unresolvable reduced to monovm.com
bbc.co.uk correct by luck of splitting at the first dot resolved against known suffixes
Missing available phrase in the server list matches everything, so every domain looks free never matches
Bulk checks sequential parallel, bounded by bulkConcurrency
Unicode input passed through Punycode encoded and validated

The first one is the important one. A server that accepts a connection and then closes it without sending anything has told us nothing — but an empty response also contains no registration fields, and the last rule in the chain reads "no registration fields" as availability. Reporting a taken domain as free is the most expensive mistake this library could make, so BlankResponseRule refuses it.

For byte-for-byte PHP behaviour, drop that one rule:

WhoisClient.builder()
        .detector(RuleBasedAvailabilityDetector.of(AvailabilityRules.phpCompatible()))
        .build();

The remaining differences are either bug fixes or presentation concerns that do not belong in a library.

The strict rule set

The pattern tables inherited from the PHP package contain a handful of entries that demonstrably read a response wrongly. They are kept in defaults() because parity is the point of the default chain, and changing them silently would move existing callers' verdicts. AvailabilityRules.strict() repairs them:

WhoisClient client = WhoisClient.builder()
        .detector(RuleBasedAvailabilityDetector.of(AvailabilityRules.strict()))
        .build();
Response defaults() strict() The inherited mistake
Registrant Phone: +1.4045551234 available unavailable /404/, meant for RDAP status codes, matches inside a phone number
aeDA record with Registrant Contact Name: available unavailable only one field name matches the table, and "few fields" is read as "free"
Contact our freephone line available unavailable free matched as a bare substring
.fr record saying "data is available at…" available unavailable two dozen extensions list the single word available
.uk: "has not been registered" unavailable available .uk lists a bare /registered/i, which matches inside a negation
.io: response opening "Domain not found" unavailable available ---domain not found is filed as evidence the name is taken

The first four are the direction that costs money, so if you are showing results to a customer, use strict(). Its trade is that an unrecognised registry may report a free name as taken, which loses a sale rather than promising one that cannot be delivered. Both chains use the same rule names, so diagnostic reports stay comparable, and StrictDetectionPatterns derives its tables from the base ones so a phrase added for a new registry is picked up by both.


How a lookup is put together

Four collaborators, each an interface, each replaceable on its own:

  "www.MonoVM.com"
        │
        ▼
  DomainParser ──────────► monovm + .com          split input into name and extension
        │
        ▼
  TldRegistry ───────────► socket://whois.crsnic.net    which server serves .com
        │
        ▼
  WhoisTransport ────────► "Domain Name: MONOVM.COM…"   ask it, over port 43 or RDAP
        │
        ▼
  AvailabilityDetector ──► NOT_AVAILABLE + why          read the answer

Behaviour is changed by composing different pieces rather than by editing the pipeline.

Transports compose

Cross-cutting concerns are decorators, so each is one small class and they stack in any order:

WhoisTransport transport =
    CachingWhoisTransport.of(                    // a repeated question costs nothing
        RetryingWhoisTransport.of(               // registries drop connections under load
            RateLimitingWhoisTransport.of(       // and ban clients that ask too fast
                RoutingWhoisTransport.standard(Duration.ofSeconds(10)),
                Duration.ofMillis(500)),
            3),
        Duration.ofMinutes(10));

WhoisClient client = WhoisClient.builder().transport(transport).build();

The order is meaningful: caching outside retrying means a cached answer costs nothing even for a domain whose first lookup needed three attempts. The usual stack is built for you from WhoisOptions — reach for this only when you want something unusual.

ReferralFollowingWhoisTransport additionally follows a thin registry's pointer to the registrar's own server, which is what turns Verisign's four-line stub into a full record.

Detection is a chain of rules

WHOIS has no schema. Every registry words "this name is free" differently, several word it in a way that also appears in their legal boilerplate, and a few answer a question you did not ask. So detection is many small tests applied in a fixed order, the first one to take a position winning.

The ordering principle: every check that can establish registration runs before every check that can establish availability. A loosely worded availability pattern must never be able to overrule a registration record.

# Rule Concludes
1 registry-available-phrase the phrase curated for this registry beats any heuristic
2 blank-response silence is not an answer
3 server-error a refusal to answer is not an empty registry
4 unavailability-indicators an explicit "registered" beats an inference
5 rdap-registered a structured RDAP record is conclusive
6 registration-indicators three record fields outweigh any wording
7 availability-keywords registration ruled out, now read the words
8 no-match-patterns the same statements, tolerant of odd whitespace
9 tld-specific-patterns per-registry wording, loose enough to need everything above first
10 short-response too small to be a record — reporting only, never votes
11 domain-status-indicators an explicit status field, or no record fields at all

When every rule abstains the answer is "not available", which is the conservative default.

Rule 11's second half — "no record fields at all" — is the weakest signal in the chain, and the one strict() switches off.

Teaching it about a registry takes a rule, not a fork:

AvailabilityRule myRegistry = new AvailabilityRule() {
    public String name() { return "example-registry"; }
    public RuleOutcome evaluate(DetectionContext context) {
        return context.contains("% no entries for this name")
                ? RuleOutcome.available("registry-specific phrase")
                : RuleOutcome.abstain();
    }
};

WhoisClient client = WhoisClient.builder()
        .detector(RuleBasedAvailabilityDetector.builder().addFirst(myRegistry).build())
        .build();

The builder also supports addLast, addBefore, replace and remove, so a rule you disagree with can be dropped without touching the rest.

The server list

890 extension declarations across 285 registries, bundled in the jar as com/monovm/whois/registry/dist.whois.json — the same file the PHP package ships.

Override individual extensions without forking by putting your own whois.json at that classpath location; it is layered on top. Or supply a registry outright:

TldRegistry registry = CompositeTldRegistry.of(
        TldRegistries.bundled(),
        TldRegistries.fromPath(Path.of("/etc/whois-overrides.json")));

WhoisClient client = WhoisClient.builder().registry(registry).build();

Configuration

WhoisOptions holds the plain settings; the client builder wires up collaborators. Keeping the two apart is what stops the constructor becoming a twenty-argument list.

WhoisOptions options = WhoisOptions.builder()
        .timeout(Duration.ofSeconds(5))              // connect and read
        .maxAttempts(3)                              // retry with exponential backoff
        .retryBackoff(Duration.ofMillis(250))
        .minimumHostInterval(Duration.ofMillis(400)) // per WHOIS host, not global
        .cacheTimeToLive(Duration.ofMinutes(15))
        .followReferrals(true)
        .bulkConcurrency(16)
        .popularTlds("com", "net", "dev")
        .charset(StandardCharsets.UTF_8)
        .build();

WhoisClient client = WhoisClient.create(options);
Setting Default Notes
connectTimeout / readTimeout 10s timeout(…) sets both
maxAttempts 2 1 disables retrying
retryBackoff 250ms doubles per attempt
minimumHostInterval off throttling is opt-in
cacheTimeToLive off caching is opt-in
followReferrals false costs an extra round trip
bulkConcurrency 8 parallel lookups per batch
popularTlds com, net, org, info used for bare names
charset UTF-8 invalid bytes fall back to ISO-8859-1 rather than becoming ?

Observing lookups

No logging framework is imposed. Register a listener:

WhoisClient client = WhoisClient.builder()
        .listener(new WhoisEventListener() {
            @Override public void onLookupCompleted(WhoisResult result) {
                metrics.timer("whois." + result.availability().code()).record(result.elapsed());
            }
        })
        .build();

An exception from a listener is swallowed rather than allowed to fail the lookup.


Command line

The jar is runnable:

$ java -jar monovm-whois-java-1.0.0.jar monovm.com example.dev
monovm.com   unavailable    412 ms
example.dev  unavailable    233 ms

$ java -jar monovm-whois-java-1.0.0.jar --json --details monovm.com
$ java -jar monovm-whois-java-1.0.0.jar --tlds com,net,dev monovm
$ java -jar monovm-whois-java-1.0.0.jar --help

Exit status is 0 when every name got a real answer, 1 when a lookup failed, 2 for a usage error, so it drops into a shell script without output parsing.


Testing against it

WhoisTransport is a single-method interface, so no network is needed to test code that uses this library:

WhoisClient client = WhoisClient.builder()
        .registry(InMemoryTldRegistry.builder()
                .add(WhoisServerDefinition.builder()
                        .uri("socket://whois.example.test")
                        .availableMatch("No match for")
                        .extensions(Tld.of("com"))
                        .build())
                .build())
        .transport(request -> new WhoisResponse(request.domain(), request.endpoint(),
                "No match for EXAMPLE.COM", Duration.ZERO))
        .build();

assertThat(client.lookup("example.com").isAvailable()).isTrue();

Parity with the PHP package

src/test/resources/fixtures/ holds 36 recorded registry responses covering the awkward cases: Verisign thin records, DENIC's Status: connect, IRNIC banners, RDAP objects and RDAP 404s, throttling notices, wrong-server replies, reserved-name refusals, an empty response, and the registries whose field names sit just outside the tables — aeDA, NASK, registro.br, IIS, TCI, CNNIC, auDA. fixtures/php-parity.json records what monovm/whois-php decides for each of them — generated by running the PHP implementation itself, not by reading its source:

php tools/generate-php-parity.php /path/to/whois-php

PhpParityTest then asserts, per fixture, that this library reaches the same verdict, and that its diagnostic detail map matches PHP's field for field. WhoisClientTest.EndToEndParity does the same for the end-to-end status, premium detection included.

Running the tests

mvn test                              # 356 tests, no network needed
mvn -Dwhois.liveTests=true test       # also queries real registries

Design notes

For anyone extending this, the shape is deliberate:

  • Value objects. Tld, DomainName, DomainQuery, WhoisEndpoint, WhoisRequest, WhoisResponse, DetectionResult are immutable and normalise on construction, so Tld.of("COM") and Tld.of(".com") are the same value and an invalid name cannot be built at all.
  • Strategy for every decision that reasonably varies: DomainParser, TldRegistry, WhoisTransport, AvailabilityDetector, AvailabilityRule.
  • Chain of responsibility for detection, which is the only structure that copes with a hundred registries wording the same fact differently.
  • Decorator for retrying, caching, throttling and referral following — each one class, composable in any order, none of them touching the protocol code.
  • Template method in AbstractWhoisTransport, which fixes timing and charset handling so no transport can get them subtly wrong.
  • Builder wherever a type has more than three optional fields.
  • Composite for layering registries.
  • Facade in WhoisClient, Checker and WhoisHandler.
  • Null object for the event listener, so the lookup path has no null checks.
  • Sealed hierarchies for WhoisEndpoint and RuleOutcome, so handling code cannot silently miss a case.

Nothing in com.monovm.whois.internal is exported or covered by the compatibility promise.


Publishing

The release profile attaches sources and javadoc, signs everything, and deploys through the Sonatype Central Portal:

mvn -Prelease clean deploy

It needs a GPG key and a Central Portal token in ~/.m2/settings.xml under the server id central, and the com.monovm namespace verified — which is done by publishing a DNS TXT record on monovm.com. autoPublish is off, so a deployment waits for a manual release in the portal.


Contributing

New extension, new registry wording, or a bug: pull requests welcome. Please add a fixture for the response that motivated the change — a pattern without a recorded response it explains is a pattern nobody can safely alter later. See CONTRIBUTING.md.

License

MIT

Support

dev@monovm.comMonoVM.com

About

Fast domain WHOIS lookup and availability checking for Java. Port 43 and RDAP/HTTP, bundled server list covering ~890 TLDs, zero runtime dependencies.

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages