summaryrefslogtreecommitdiffstats
path: root/includes/class-cepis-woo-lookup.php
blob: 47e3f16e3e69a3d36f4366e6c5569ae8cdbdee57 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
<?php
/**
 * Server-side CEP lookups, cached.
 *
 * @package CepisWooCommerce
 */

declare(strict_types=1);

defined( 'ABSPATH' ) || exit;

/**
 * Resolves a CEP with the bundled `Cepis\Client` and remembers the answer in a
 * transient.
 *
 * Only the proxy mode goes through here. In the default direct mode the
 * browser talks to cep.is itself and no PHP runs at all.
 */
final class Cepis_Woo_Lookup {

	/**
	 * Prefix for the transient holding one CEP's answer.
	 */
	private const CACHE_PREFIX = 'cepis_woo_';

	/**
	 * How long a resolved address is kept.
	 *
	 * The DNE database behind the API is regenerated fortnightly, so a week is
	 * half a refresh cycle: an address can be at most one release behind, while
	 * a CEP a store sees repeatedly still costs one outbound request a week.
	 */
	private const HIT_TTL = WEEK_IN_SECONDS;

	/**
	 * How long a "this CEP does not exist" is kept.
	 *
	 * Much shorter than a hit, and for a different reason: a CEP absent today
	 * can appear in the next fortnightly base, and telling a shopper their
	 * brand-new street does not exist for two weeks is worse than making the
	 * request again tomorrow.
	 */
	private const MISS_TTL = DAY_IN_SECONDS;

	/**
	 * Look a CEP up, from cache when possible.
	 *
	 * Never throws: every outcome is described by the returned status and body,
	 * in exactly the shapes the public API uses, so the browser sees the same
	 * thing whichever mode the store is in.
	 *
	 * @param string $cep Eight digits, already validated by the caller.
	 *
	 * @return array{status:int, body:array<string, mixed>, retry_after:?int}
	 */
	public static function resolve( string $cep ): array {
		$cached = get_transient( self::CACHE_PREFIX . $cep );

		if ( is_array( $cached ) ) {
			return isset( $cached['miss'] )
				? self::not_found()
				: array(
					'status'      => 200,
					'body'        => $cached,
					'retry_after' => null,
				);
		}

		try {
			$address = self::client()->lookup( $cep );
		} catch ( Cepis\CepNotFoundException ) {
			set_transient( self::CACHE_PREFIX . $cep, array( 'miss' => true ), self::MISS_TTL );

			return self::not_found();
		} catch ( Cepis\InvalidCepException ) {
			return array(
				'status'      => 400,
				'body'        => array(
					'erro'     => 'cep_invalido',
					'mensagem' => 'CEP deve ter 8 dígitos',
				),
				'retry_after' => null,
			);
		} catch ( Cepis\RateLimitedException $e ) {
			return array(
				'status'      => 429,
				'body'        => array(
					'erro'     => 'limite',
					'mensagem' => 'Muitas consultas em pouco tempo',
				),
				'retry_after' => null === $e->retryAfter ? null : (int) $e->retryAfter,
			);
		} catch ( Cepis\CepisException ) {
			return array(
				'status'      => 502,
				'body'        => array(
					'erro'     => 'interno',
					'mensagem' => 'falha interna',
				),
				'retry_after' => null,
			);
		}

		$body = $address->raw;
		set_transient( self::CACHE_PREFIX . $cep, $body, self::HIT_TTL );

		return array(
			'status'      => 200,
			'body'        => $body,
			'retry_after' => null,
		);
	}

	/**
	 * Drop every cached answer.
	 *
	 * Used by the settings screen when the API root changes, since answers from
	 * one instance say nothing about another.
	 */
	public static function flush(): void {
		global $wpdb;

		$like = $wpdb->esc_like( '_transient_' . self::CACHE_PREFIX ) . '%';

		$names = $wpdb->get_col(
			$wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $like )
		);

		foreach ( (array) $names as $name ) {
			delete_transient( substr( (string) $name, strlen( '_transient_' ) ) );
		}
	}

	/**
	 * The client, built once per request.
	 */
	private static function client(): Cepis\Client {
		static $client = null;

		if ( null === $client ) {
			$client = new Cepis\Client(
				baseUrl: Cepis_Woo_Options::base_url(),
				timeout: 5.0,
				maxAttempts: 2,
				totalTimeout: 8.0,
				userAgent: 'cepis-woocommerce/' . CEPIS_WOO_VERSION . ' (+https://cep.is)',
				transport: new Cepis_Woo_Transport(),
			);
		}

		return $client;
	}

	/**
	 * The API's own 404 body, reproduced so the browser cannot tell the two
	 * modes apart.
	 *
	 * @return array{status:int, body:array<string, mixed>, retry_after:?int}
	 */
	private static function not_found(): array {
		return array(
			'status'      => 404,
			'body'        => array(
				'erro'     => 'cep_nao_encontrado',
				'mensagem' => 'CEP não existe na base do DNE',
			),
			'retry_after' => null,
		);
	}
}